refactor: Consolidate standalone services into admin-v2, add new SDK modules

Remove standalone services (ai-compliance-sdk root, developer-portal,
dsms-gateway, dsms-node, night-scheduler) and legacy compliance/dsgvo pages.
Add new SDK pipeline modules (academy, document-crawler, dsb-portal,
incidents, whistleblower, reporting, sso, multi-tenant, industry-templates).
Add drafting engine, legal corpus files (AT/CH/DE), pitch-deck,
blog and Förderantrag pages.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-15 09:05:18 +01:00
parent 626f4966e2
commit 70f2b0ae64
396 changed files with 43163 additions and 80397 deletions

View File

@@ -96,6 +96,43 @@ func main() {
checkpointHandler := api.NewCheckpointHandler()
v1.GET("/checkpoints", checkpointHandler.GetAll)
v1.POST("/checkpoints/validate", checkpointHandler.Validate)
// Academy (Compliance E-Learning)
academyHandler := api.NewAcademyHandler(dbPool, llmService, ragService)
academy := v1.Group("/academy")
{
// Course CRUD
academy.GET("/courses", academyHandler.ListCourses)
academy.GET("/courses/:id", academyHandler.GetCourse)
academy.POST("/courses", academyHandler.CreateCourse)
academy.PUT("/courses/:id", academyHandler.UpdateCourse)
academy.DELETE("/courses/:id", academyHandler.DeleteCourse)
// Statistics
academy.GET("/statistics", academyHandler.GetStatistics)
// Enrollments
academy.GET("/enrollments", academyHandler.ListEnrollments)
academy.POST("/enrollments", academyHandler.EnrollUser)
academy.PUT("/enrollments/:id/progress", academyHandler.UpdateProgress)
academy.POST("/enrollments/:id/complete", academyHandler.CompleteEnrollment)
// Quiz
academy.POST("/lessons/:id/quiz", academyHandler.SubmitQuiz)
// Certificates
academy.POST("/enrollments/:id/certificate", academyHandler.GenerateCertificateEndpoint)
academy.GET("/certificates/:id", academyHandler.GetCertificate)
academy.GET("/certificates/:id/pdf", academyHandler.DownloadCertificatePDF)
// AI Course Generation
academy.POST("/courses/generate", academyHandler.GenerateCourse)
academy.POST("/lessons/:id/regenerate", academyHandler.RegenerateLesson)
// Video Generation
academy.POST("/courses/:id/generate-videos", academyHandler.GenerateVideos)
academy.GET("/courses/:id/video-status", academyHandler.GetVideoStatus)
}
}
// Create server

View File

@@ -1,11 +1,45 @@
module github.com/breakpilot/ai-compliance-sdk
go 1.21
go 1.23
require (
github.com/gin-gonic/gin v1.10.0
github.com/jackc/pgx/v5 v5.5.1
github.com/joho/godotenv v1.5.1
github.com/qdrant/go-client v1.7.0
gopkg.in/yaml.v3 v3.0.1
github.com/jung-kurt/gofpdf v1.16.2
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,119 @@
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI=
github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -0,0 +1,152 @@
package academy
import (
"bytes"
"fmt"
"time"
"github.com/jung-kurt/gofpdf"
)
// CertificateData holds all data needed to generate a certificate PDF
type CertificateData struct {
CertificateID string
UserName string
CourseName string
CompanyName string
Score int
IssuedAt time.Time
ValidUntil time.Time
}
// GenerateCertificatePDF generates a PDF certificate and returns the bytes
func GenerateCertificatePDF(data CertificateData) ([]byte, error) {
pdf := gofpdf.New("L", "mm", "A4", "") // Landscape A4
pdf.SetAutoPageBreak(false, 0)
pdf.AddPage()
pageWidth, pageHeight := pdf.GetPageSize()
// Background color - light gray
pdf.SetFillColor(250, 250, 252)
pdf.Rect(0, 0, pageWidth, pageHeight, "F")
// Border - decorative
pdf.SetDrawColor(79, 70, 229) // Purple/Indigo
pdf.SetLineWidth(3)
pdf.Rect(10, 10, pageWidth-20, pageHeight-20, "D")
pdf.SetLineWidth(1)
pdf.Rect(14, 14, pageWidth-28, pageHeight-28, "D")
// Header - Company/BreakPilot Logo area
companyName := data.CompanyName
if companyName == "" {
companyName = "BreakPilot Compliance"
}
pdf.SetFont("Helvetica", "", 12)
pdf.SetTextColor(120, 120, 120)
pdf.SetXY(0, 25)
pdf.CellFormat(pageWidth, 10, companyName, "", 0, "C", false, 0, "")
// Title
pdf.SetFont("Helvetica", "B", 32)
pdf.SetTextColor(30, 30, 30)
pdf.SetXY(0, 42)
pdf.CellFormat(pageWidth, 15, "SCHULUNGSZERTIFIKAT", "", 0, "C", false, 0, "")
// Decorative line
pdf.SetDrawColor(79, 70, 229)
pdf.SetLineWidth(1.5)
lineY := 62.0
pdf.Line(pageWidth/2-60, lineY, pageWidth/2+60, lineY)
// "Hiermit wird bescheinigt, dass"
pdf.SetFont("Helvetica", "", 13)
pdf.SetTextColor(80, 80, 80)
pdf.SetXY(0, 72)
pdf.CellFormat(pageWidth, 8, "Hiermit wird bescheinigt, dass", "", 0, "C", false, 0, "")
// Name
pdf.SetFont("Helvetica", "B", 26)
pdf.SetTextColor(30, 30, 30)
pdf.SetXY(0, 85)
pdf.CellFormat(pageWidth, 12, data.UserName, "", 0, "C", false, 0, "")
// "die folgende Compliance-Schulung erfolgreich abgeschlossen hat:"
pdf.SetFont("Helvetica", "", 13)
pdf.SetTextColor(80, 80, 80)
pdf.SetXY(0, 103)
pdf.CellFormat(pageWidth, 8, "die folgende Compliance-Schulung erfolgreich abgeschlossen hat:", "", 0, "C", false, 0, "")
// Course Name
pdf.SetFont("Helvetica", "B", 20)
pdf.SetTextColor(79, 70, 229)
pdf.SetXY(0, 116)
pdf.CellFormat(pageWidth, 10, data.CourseName, "", 0, "C", false, 0, "")
// Score
if data.Score > 0 {
pdf.SetFont("Helvetica", "", 12)
pdf.SetTextColor(80, 80, 80)
pdf.SetXY(0, 130)
pdf.CellFormat(pageWidth, 8, fmt.Sprintf("Testergebnis: %d%%", data.Score), "", 0, "C", false, 0, "")
}
// Bottom section - Dates and Signature
bottomY := 148.0
// Left: Issued Date
pdf.SetFont("Helvetica", "", 10)
pdf.SetTextColor(100, 100, 100)
pdf.SetXY(40, bottomY)
pdf.CellFormat(80, 6, fmt.Sprintf("Abschlussdatum: %s", data.IssuedAt.Format("02.01.2006")), "", 0, "L", false, 0, "")
// Center: Valid Until
pdf.SetXY(pageWidth/2-40, bottomY)
pdf.CellFormat(80, 6, fmt.Sprintf("Gueltig bis: %s", data.ValidUntil.Format("02.01.2006")), "", 0, "C", false, 0, "")
// Right: Certificate ID
pdf.SetXY(pageWidth-120, bottomY)
pdf.CellFormat(80, 6, fmt.Sprintf("Zertifikats-Nr.: %s", data.CertificateID[:min(12, len(data.CertificateID))]), "", 0, "R", false, 0, "")
// Signature line
sigY := 162.0
pdf.SetDrawColor(150, 150, 150)
pdf.SetLineWidth(0.5)
// Left signature
pdf.Line(50, sigY, 130, sigY)
pdf.SetFont("Helvetica", "", 9)
pdf.SetTextColor(120, 120, 120)
pdf.SetXY(50, sigY+2)
pdf.CellFormat(80, 5, "Datenschutzbeauftragter", "", 0, "C", false, 0, "")
// Right signature
pdf.Line(pageWidth-130, sigY, pageWidth-50, sigY)
pdf.SetXY(pageWidth-130, sigY+2)
pdf.CellFormat(80, 5, "Geschaeftsfuehrung", "", 0, "C", false, 0, "")
// Footer
pdf.SetFont("Helvetica", "", 8)
pdf.SetTextColor(160, 160, 160)
pdf.SetXY(0, pageHeight-22)
pdf.CellFormat(pageWidth, 5, "Dieses Zertifikat wurde elektronisch erstellt und ist ohne Unterschrift gueltig.", "", 0, "C", false, 0, "")
pdf.SetXY(0, pageHeight-17)
pdf.CellFormat(pageWidth, 5, fmt.Sprintf("Verifizierung unter: https://compliance.breakpilot.de/verify/%s", data.CertificateID), "", 0, "C", false, 0, "")
// Generate PDF bytes
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("failed to generate PDF: %w", err)
}
return buf.Bytes(), nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,105 @@
package academy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
// ElevenLabsClient handles text-to-speech via the ElevenLabs API
type ElevenLabsClient struct {
apiKey string
voiceID string
client *http.Client
}
// NewElevenLabsClient creates a new ElevenLabs client
func NewElevenLabsClient() *ElevenLabsClient {
apiKey := os.Getenv("ELEVENLABS_API_KEY")
voiceID := os.Getenv("ELEVENLABS_VOICE_ID")
if voiceID == "" {
voiceID = "EXAVITQu4vr4xnSDxMaL" // Default: "Sarah" voice
}
return &ElevenLabsClient{
apiKey: apiKey,
voiceID: voiceID,
client: &http.Client{
Timeout: 120 * time.Second,
},
}
}
// IsConfigured returns true if API key is set
func (c *ElevenLabsClient) IsConfigured() bool {
return c.apiKey != ""
}
// TextToSpeechRequest represents the API request
type TextToSpeechRequest struct {
Text string `json:"text"`
ModelID string `json:"model_id"`
VoiceSettings VoiceSettings `json:"voice_settings"`
}
// VoiceSettings controls voice parameters
type VoiceSettings struct {
Stability float64 `json:"stability"`
SimilarityBoost float64 `json:"similarity_boost"`
Style float64 `json:"style"`
}
// TextToSpeech converts text to speech audio (MP3)
func (c *ElevenLabsClient) TextToSpeech(text string) ([]byte, error) {
if !c.IsConfigured() {
return nil, fmt.Errorf("ElevenLabs API key not configured")
}
url := fmt.Sprintf("https://api.elevenlabs.io/v1/text-to-speech/%s", c.voiceID)
reqBody := TextToSpeechRequest{
Text: text,
ModelID: "eleven_multilingual_v2",
VoiceSettings: VoiceSettings{
Stability: 0.5,
SimilarityBoost: 0.75,
Style: 0.5,
},
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("xi-api-key", c.apiKey)
req.Header.Set("Accept", "audio/mpeg")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("ElevenLabs API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ElevenLabs API error %d: %s", resp.StatusCode, string(body))
}
audioData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read audio response: %w", err)
}
return audioData, nil
}

View File

@@ -0,0 +1,184 @@
package academy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
// HeyGenClient handles avatar video generation via the HeyGen API
type HeyGenClient struct {
apiKey string
avatarID string
client *http.Client
}
// NewHeyGenClient creates a new HeyGen client
func NewHeyGenClient() *HeyGenClient {
apiKey := os.Getenv("HEYGEN_API_KEY")
avatarID := os.Getenv("HEYGEN_AVATAR_ID")
if avatarID == "" {
avatarID = "josh_lite3_20230714" // Default avatar
}
return &HeyGenClient{
apiKey: apiKey,
avatarID: avatarID,
client: &http.Client{
Timeout: 300 * time.Second, // Video generation can take time
},
}
}
// IsConfigured returns true if API key is set
func (c *HeyGenClient) IsConfigured() bool {
return c.apiKey != ""
}
// CreateVideoRequest represents the HeyGen API request
type CreateVideoRequest struct {
VideoInputs []VideoInput `json:"video_inputs"`
Dimension Dimension `json:"dimension"`
}
// VideoInput represents a single video segment
type VideoInput struct {
Character Character `json:"character"`
Voice VideoVoice `json:"voice"`
}
// Character represents the avatar
type Character struct {
Type string `json:"type"`
AvatarID string `json:"avatar_id"`
}
// VideoVoice represents the voice/audio source
type VideoVoice struct {
Type string `json:"type"` // "audio" for pre-generated audio
AudioURL string `json:"audio_url,omitempty"`
InputText string `json:"input_text,omitempty"`
}
// Dimension represents video dimensions
type Dimension struct {
Width int `json:"width"`
Height int `json:"height"`
}
// CreateVideoResponse represents the HeyGen API response
type CreateVideoResponse struct {
Data struct {
VideoID string `json:"video_id"`
} `json:"data"`
Error interface{} `json:"error"`
}
// HeyGenVideoStatus represents video status from HeyGen
type HeyGenVideoStatus struct {
Data struct {
Status string `json:"status"` // processing, completed, failed
VideoURL string `json:"video_url"`
} `json:"data"`
}
// CreateVideo creates a video with the avatar and audio
func (c *HeyGenClient) CreateVideo(audioURL string) (*CreateVideoResponse, error) {
if !c.IsConfigured() {
return nil, fmt.Errorf("HeyGen API key not configured")
}
url := "https://api.heygen.com/v2/video/generate"
reqBody := CreateVideoRequest{
VideoInputs: []VideoInput{
{
Character: Character{
Type: "avatar",
AvatarID: c.avatarID,
},
Voice: VideoVoice{
Type: "audio",
AudioURL: audioURL,
},
},
},
Dimension: Dimension{
Width: 1920,
Height: 1080,
},
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", c.apiKey)
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("HeyGen API request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("HeyGen API error %d: %s", resp.StatusCode, string(body))
}
var result CreateVideoResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}
// GetVideoStatus checks the status of a video generation job
func (c *HeyGenClient) GetVideoStatus(videoID string) (*HeyGenVideoStatus, error) {
if !c.IsConfigured() {
return nil, fmt.Errorf("HeyGen API key not configured")
}
url := fmt.Sprintf("https://api.heygen.com/v1/video_status.get?video_id=%s", videoID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("X-Api-Key", c.apiKey)
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("HeyGen API request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var status HeyGenVideoStatus
if err := json.Unmarshal(body, &status); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &status, nil
}

View File

@@ -0,0 +1,91 @@
package academy
import (
"fmt"
"log"
)
// VideoGenerator orchestrates video generation with 3-tier fallback:
// 1. HeyGen + ElevenLabs -> Avatar video with voice
// 2. ElevenLabs only -> Audio podcast style
// 3. No external services -> Text + Quiz only
type VideoGenerator struct {
elevenLabs *ElevenLabsClient
heyGen *HeyGenClient
}
// NewVideoGenerator creates a new video generator
func NewVideoGenerator() *VideoGenerator {
return &VideoGenerator{
elevenLabs: NewElevenLabsClient(),
heyGen: NewHeyGenClient(),
}
}
// GenerationMode describes the available generation mode
type GenerationMode string
const (
ModeAvatarVideo GenerationMode = "avatar_video" // HeyGen + ElevenLabs
ModeAudioOnly GenerationMode = "audio_only" // ElevenLabs only
ModeTextOnly GenerationMode = "text_only" // No external services
)
// GetAvailableMode returns the best available generation mode
func (vg *VideoGenerator) GetAvailableMode() GenerationMode {
if vg.heyGen.IsConfigured() && vg.elevenLabs.IsConfigured() {
return ModeAvatarVideo
}
if vg.elevenLabs.IsConfigured() {
return ModeAudioOnly
}
return ModeTextOnly
}
// GenerateAudio generates audio from text using ElevenLabs
func (vg *VideoGenerator) GenerateAudio(text string) ([]byte, error) {
if !vg.elevenLabs.IsConfigured() {
return nil, fmt.Errorf("ElevenLabs not configured")
}
log.Printf("Generating audio for text (%d chars)...", len(text))
return vg.elevenLabs.TextToSpeech(text)
}
// GenerateVideo generates a video from audio using HeyGen
func (vg *VideoGenerator) GenerateVideo(audioURL string) (string, error) {
if !vg.heyGen.IsConfigured() {
return "", fmt.Errorf("HeyGen not configured")
}
log.Printf("Creating HeyGen video with audio: %s", audioURL)
resp, err := vg.heyGen.CreateVideo(audioURL)
if err != nil {
return "", err
}
return resp.Data.VideoID, nil
}
// CheckVideoStatus checks if a HeyGen video is ready
func (vg *VideoGenerator) CheckVideoStatus(videoID string) (string, string, error) {
if !vg.heyGen.IsConfigured() {
return "", "", fmt.Errorf("HeyGen not configured")
}
status, err := vg.heyGen.GetVideoStatus(videoID)
if err != nil {
return "", "", err
}
return status.Data.Status, status.Data.VideoURL, nil
}
// GetStatus returns the configuration status
func (vg *VideoGenerator) GetStatus() map[string]interface{} {
return map[string]interface{}{
"mode": string(vg.GetAvailableMode()),
"elevenLabsConfigured": vg.elevenLabs.IsConfigured(),
"heyGenConfigured": vg.heyGen.IsConfigured(),
}
}

View File

@@ -0,0 +1,950 @@
package api
import (
"fmt"
"net/http"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
"github.com/breakpilot/ai-compliance-sdk/internal/db"
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
"github.com/breakpilot/ai-compliance-sdk/internal/rag"
"github.com/gin-gonic/gin"
)
// AcademyHandler handles all Academy-related HTTP requests
type AcademyHandler struct {
dbPool *db.Pool
llmService *llm.Service
ragService *rag.Service
academyStore *db.AcademyMemStore
}
// NewAcademyHandler creates a new Academy handler
func NewAcademyHandler(dbPool *db.Pool, llmService *llm.Service, ragService *rag.Service) *AcademyHandler {
return &AcademyHandler{
dbPool: dbPool,
llmService: llmService,
ragService: ragService,
academyStore: db.NewAcademyMemStore(),
}
}
func (h *AcademyHandler) getTenantID(c *gin.Context) string {
tid := c.GetHeader("X-Tenant-ID")
if tid == "" {
tid = c.Query("tenantId")
}
if tid == "" {
tid = "default-tenant"
}
return tid
}
// ---------------------------------------------------------------------------
// Course CRUD
// ---------------------------------------------------------------------------
// ListCourses returns all courses for the tenant
func (h *AcademyHandler) ListCourses(c *gin.Context) {
tenantID := h.getTenantID(c)
rows := h.academyStore.ListCourses(tenantID)
courses := make([]AcademyCourse, 0, len(rows))
for _, row := range rows {
lessons := h.buildLessonsForCourse(row.ID)
courses = append(courses, courseRowToResponse(row, lessons))
}
SuccessResponse(c, courses)
}
// GetCourse returns a single course with its lessons
func (h *AcademyHandler) GetCourse(c *gin.Context) {
id := c.Param("id")
row, err := h.academyStore.GetCourse(id)
if err != nil {
ErrorResponse(c, http.StatusNotFound, "Course not found", "COURSE_NOT_FOUND")
return
}
lessons := h.buildLessonsForCourse(row.ID)
SuccessResponse(c, courseRowToResponse(row, lessons))
}
// CreateCourse creates a new course with optional lessons
func (h *AcademyHandler) CreateCourse(c *gin.Context) {
var req CreateCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
return
}
passingScore := req.PassingScore
if passingScore == 0 {
passingScore = 70
}
roles := req.RequiredForRoles
if len(roles) == 0 {
roles = []string{"all"}
}
courseRow := h.academyStore.CreateCourse(&db.AcademyCourseRow{
TenantID: req.TenantID,
Title: req.Title,
Description: req.Description,
Category: req.Category,
PassingScore: passingScore,
DurationMinutes: req.DurationMinutes,
RequiredForRoles: roles,
Status: "draft",
})
// Create lessons
for i, lessonReq := range req.Lessons {
order := lessonReq.Order
if order == 0 {
order = i + 1
}
lessonRow := h.academyStore.CreateLesson(&db.AcademyLessonRow{
CourseID: courseRow.ID,
Title: lessonReq.Title,
Type: lessonReq.Type,
ContentMarkdown: lessonReq.ContentMarkdown,
VideoURL: lessonReq.VideoURL,
SortOrder: order,
DurationMinutes: lessonReq.DurationMinutes,
})
// Create quiz questions for this lesson
for j, qReq := range lessonReq.QuizQuestions {
qOrder := qReq.Order
if qOrder == 0 {
qOrder = j + 1
}
h.academyStore.CreateQuizQuestion(&db.AcademyQuizQuestionRow{
LessonID: lessonRow.ID,
Question: qReq.Question,
Options: qReq.Options,
CorrectOptionIndex: qReq.CorrectOptionIndex,
Explanation: qReq.Explanation,
SortOrder: qOrder,
})
}
}
lessons := h.buildLessonsForCourse(courseRow.ID)
c.JSON(http.StatusCreated, Response{
Success: true,
Data: courseRowToResponse(courseRow, lessons),
})
}
// UpdateCourse updates an existing course
func (h *AcademyHandler) UpdateCourse(c *gin.Context) {
id := c.Param("id")
var req UpdateCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
return
}
updates := make(map[string]interface{})
if req.Title != nil {
updates["title"] = *req.Title
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.Category != nil {
updates["category"] = *req.Category
}
if req.DurationMinutes != nil {
updates["durationminutes"] = *req.DurationMinutes
}
if req.PassingScore != nil {
updates["passingscore"] = *req.PassingScore
}
if req.RequiredForRoles != nil {
updates["requiredforroles"] = req.RequiredForRoles
}
row, err := h.academyStore.UpdateCourse(id, updates)
if err != nil {
ErrorResponse(c, http.StatusNotFound, "Course not found", "COURSE_NOT_FOUND")
return
}
lessons := h.buildLessonsForCourse(row.ID)
SuccessResponse(c, courseRowToResponse(row, lessons))
}
// DeleteCourse deletes a course and all related data
func (h *AcademyHandler) DeleteCourse(c *gin.Context) {
id := c.Param("id")
if err := h.academyStore.DeleteCourse(id); err != nil {
ErrorResponse(c, http.StatusNotFound, "Course not found", "COURSE_NOT_FOUND")
return
}
SuccessResponse(c, gin.H{
"courseId": id,
"deletedAt": now(),
})
}
// GetStatistics returns academy statistics for the tenant
func (h *AcademyHandler) GetStatistics(c *gin.Context) {
tenantID := h.getTenantID(c)
stats := h.academyStore.GetStatistics(tenantID)
SuccessResponse(c, AcademyStatistics{
TotalCourses: stats.TotalCourses,
TotalEnrollments: stats.TotalEnrollments,
CompletionRate: int(stats.CompletionRate),
OverdueCount: stats.OverdueCount,
ByCategory: stats.ByCategory,
ByStatus: stats.ByStatus,
})
}
// ---------------------------------------------------------------------------
// Enrollments
// ---------------------------------------------------------------------------
// ListEnrollments returns enrollments filtered by tenant and optionally course
func (h *AcademyHandler) ListEnrollments(c *gin.Context) {
tenantID := h.getTenantID(c)
courseID := c.Query("courseId")
rows := h.academyStore.ListEnrollments(tenantID, courseID)
enrollments := make([]AcademyEnrollment, 0, len(rows))
for _, row := range rows {
enrollments = append(enrollments, enrollmentRowToResponse(row))
}
SuccessResponse(c, enrollments)
}
// EnrollUser enrolls a user in a course
func (h *AcademyHandler) EnrollUser(c *gin.Context) {
var req EnrollUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
return
}
deadline, err := time.Parse(time.RFC3339, req.Deadline)
if err != nil {
deadline, err = time.Parse("2006-01-02", req.Deadline)
if err != nil {
ErrorResponse(c, http.StatusBadRequest, "Invalid deadline format. Use RFC3339 or YYYY-MM-DD.", "INVALID_DEADLINE")
return
}
}
row := h.academyStore.CreateEnrollment(&db.AcademyEnrollmentRow{
TenantID: req.TenantID,
CourseID: req.CourseID,
UserID: req.UserID,
UserName: req.UserName,
UserEmail: req.UserEmail,
Status: "not_started",
Progress: 0,
Deadline: deadline,
})
c.JSON(http.StatusCreated, Response{
Success: true,
Data: enrollmentRowToResponse(row),
})
}
// UpdateProgress updates the progress of an enrollment
func (h *AcademyHandler) UpdateProgress(c *gin.Context) {
id := c.Param("id")
var req UpdateProgressRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
return
}
enrollment, err := h.academyStore.GetEnrollment(id)
if err != nil {
ErrorResponse(c, http.StatusNotFound, "Enrollment not found", "ENROLLMENT_NOT_FOUND")
return
}
updates := map[string]interface{}{
"progress": req.Progress,
}
// Auto-update status based on progress
if req.Progress >= 100 {
updates["status"] = "completed"
t := time.Now()
updates["completedat"] = &t
} else if req.Progress > 0 && enrollment.Status == "not_started" {
updates["status"] = "in_progress"
}
row, err := h.academyStore.UpdateEnrollment(id, updates)
if err != nil {
ErrorResponse(c, http.StatusInternalServerError, "Failed to update progress", "UPDATE_FAILED")
return
}
// Upsert lesson progress if lessonID provided
if req.LessonID != "" {
t := time.Now()
h.academyStore.UpsertLessonProgress(&db.AcademyLessonProgressRow{
EnrollmentID: id,
LessonID: req.LessonID,
Completed: true,
CompletedAt: &t,
})
}
SuccessResponse(c, enrollmentRowToResponse(row))
}
// CompleteEnrollment marks an enrollment as completed
func (h *AcademyHandler) CompleteEnrollment(c *gin.Context) {
id := c.Param("id")
t := time.Now()
updates := map[string]interface{}{
"status": "completed",
"progress": 100,
"completedat": &t,
}
row, err := h.academyStore.UpdateEnrollment(id, updates)
if err != nil {
ErrorResponse(c, http.StatusNotFound, "Enrollment not found", "ENROLLMENT_NOT_FOUND")
return
}
SuccessResponse(c, enrollmentRowToResponse(row))
}
// ---------------------------------------------------------------------------
// Quiz
// ---------------------------------------------------------------------------
// SubmitQuiz evaluates quiz answers for a lesson
func (h *AcademyHandler) SubmitQuiz(c *gin.Context) {
lessonID := c.Param("id")
var req SubmitQuizRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
return
}
// Get the lesson
lesson, err := h.academyStore.GetLesson(lessonID)
if err != nil {
ErrorResponse(c, http.StatusNotFound, "Lesson not found", "LESSON_NOT_FOUND")
return
}
// Get quiz questions
questions := h.academyStore.ListQuizQuestions(lessonID)
if len(questions) == 0 {
ErrorResponse(c, http.StatusBadRequest, "No quiz questions found for this lesson", "NO_QUIZ_QUESTIONS")
return
}
if len(req.Answers) != len(questions) {
ErrorResponse(c, http.StatusBadRequest,
fmt.Sprintf("Expected %d answers, got %d", len(questions), len(req.Answers)),
"ANSWER_COUNT_MISMATCH")
return
}
// Evaluate answers
correctCount := 0
results := make([]QuizQuestionResult, len(questions))
for i, q := range questions {
correct := req.Answers[i] == q.CorrectOptionIndex
if correct {
correctCount++
}
results[i] = QuizQuestionResult{
QuestionID: q.ID,
Correct: correct,
Explanation: q.Explanation,
}
}
score := 0
if len(questions) > 0 {
score = int(float64(correctCount) / float64(len(questions)) * 100)
}
// Determine pass/fail based on course's passing score
passingScore := 70 // default
course, err := h.academyStore.GetCourse(lesson.CourseID)
if err == nil && course.PassingScore > 0 {
passingScore = course.PassingScore
}
SuccessResponse(c, SubmitQuizResponse{
Score: score,
Passed: score >= passingScore,
CorrectAnswers: correctCount,
TotalQuestions: len(questions),
Results: results,
})
}
// ---------------------------------------------------------------------------
// Certificates
// ---------------------------------------------------------------------------
// GenerateCertificateEndpoint generates a certificate for a completed enrollment
func (h *AcademyHandler) GenerateCertificateEndpoint(c *gin.Context) {
enrollmentID := c.Param("id")
enrollment, err := h.academyStore.GetEnrollment(enrollmentID)
if err != nil {
ErrorResponse(c, http.StatusNotFound, "Enrollment not found", "ENROLLMENT_NOT_FOUND")
return
}
// Check if already has certificate
if enrollment.CertificateID != "" {
existing, err := h.academyStore.GetCertificate(enrollment.CertificateID)
if err == nil {
SuccessResponse(c, certificateRowToResponse(existing))
return
}
}
// Get course name
courseName := "Unbekannter Kurs"
course, err := h.academyStore.GetCourse(enrollment.CourseID)
if err == nil {
courseName = course.Title
}
issuedAt := time.Now()
validUntil := issuedAt.AddDate(1, 0, 0) // 1 year validity
cert := h.academyStore.CreateCertificate(&db.AcademyCertificateRow{
TenantID: enrollment.TenantID,
EnrollmentID: enrollmentID,
CourseID: enrollment.CourseID,
UserID: enrollment.UserID,
UserName: enrollment.UserName,
CourseName: courseName,
Score: enrollment.Progress,
IssuedAt: issuedAt,
ValidUntil: validUntil,
})
// Update enrollment with certificate ID
h.academyStore.UpdateEnrollment(enrollmentID, map[string]interface{}{
"certificateid": cert.ID,
})
c.JSON(http.StatusCreated, Response{
Success: true,
Data: certificateRowToResponse(cert),
})
}
// GetCertificate returns a certificate by ID
func (h *AcademyHandler) GetCertificate(c *gin.Context) {
id := c.Param("id")
cert, err := h.academyStore.GetCertificate(id)
if err != nil {
ErrorResponse(c, http.StatusNotFound, "Certificate not found", "CERTIFICATE_NOT_FOUND")
return
}
SuccessResponse(c, certificateRowToResponse(cert))
}
// DownloadCertificatePDF returns the PDF for a certificate
func (h *AcademyHandler) DownloadCertificatePDF(c *gin.Context) {
id := c.Param("id")
cert, err := h.academyStore.GetCertificate(id)
if err != nil {
ErrorResponse(c, http.StatusNotFound, "Certificate not found", "CERTIFICATE_NOT_FOUND")
return
}
if cert.PdfURL != "" {
c.Redirect(http.StatusFound, cert.PdfURL)
return
}
// Generate PDF on-the-fly
pdfBytes, err := academy.GenerateCertificatePDF(academy.CertificateData{
CertificateID: cert.ID,
UserName: cert.UserName,
CourseName: cert.CourseName,
CompanyName: "",
Score: cert.Score,
IssuedAt: cert.IssuedAt,
ValidUntil: cert.ValidUntil,
})
if err != nil {
ErrorResponse(c, http.StatusInternalServerError, "Failed to generate PDF", "PDF_GENERATION_FAILED")
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=zertifikat-%s.pdf", cert.ID[:min(8, len(cert.ID))]))
c.Data(http.StatusOK, "application/pdf", pdfBytes)
}
// ---------------------------------------------------------------------------
// AI Course Generation
// ---------------------------------------------------------------------------
// GenerateCourse generates a course using AI
func (h *AcademyHandler) GenerateCourse(c *gin.Context) {
var req GenerateCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
return
}
// Get RAG context if requested
var ragSources []SearchResult
if req.UseRAG && h.ragService != nil {
query := req.RAGQuery
if query == "" {
query = req.Topic + " Compliance Schulung"
}
results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "")
for _, r := range results {
ragSources = append(ragSources, SearchResult{
ID: r.ID,
Content: r.Content,
Source: r.Source,
Score: r.Score,
Metadata: r.Metadata,
})
}
}
// Generate course content (mock for now)
course := h.generateMockCourse(req)
// Save to store
courseRow := h.academyStore.CreateCourse(&db.AcademyCourseRow{
TenantID: req.TenantID,
Title: course.Title,
Description: course.Description,
Category: req.Category,
PassingScore: 70,
DurationMinutes: course.DurationMinutes,
RequiredForRoles: []string{"all"},
Status: "draft",
})
for _, lesson := range course.Lessons {
lessonRow := h.academyStore.CreateLesson(&db.AcademyLessonRow{
CourseID: courseRow.ID,
Title: lesson.Title,
Type: lesson.Type,
ContentMarkdown: lesson.ContentMarkdown,
SortOrder: lesson.Order,
DurationMinutes: lesson.DurationMinutes,
})
for _, q := range lesson.QuizQuestions {
h.academyStore.CreateQuizQuestion(&db.AcademyQuizQuestionRow{
LessonID: lessonRow.ID,
Question: q.Question,
Options: q.Options,
CorrectOptionIndex: q.CorrectOptionIndex,
Explanation: q.Explanation,
SortOrder: q.Order,
})
}
}
lessons := h.buildLessonsForCourse(courseRow.ID)
c.JSON(http.StatusCreated, Response{
Success: true,
Data: gin.H{
"course": courseRowToResponse(courseRow, lessons),
"ragSources": ragSources,
"model": h.llmService.GetModel(),
},
})
}
// RegenerateLesson regenerates a single lesson using AI
func (h *AcademyHandler) RegenerateLesson(c *gin.Context) {
lessonID := c.Param("id")
_, err := h.academyStore.GetLesson(lessonID)
if err != nil {
ErrorResponse(c, http.StatusNotFound, "Lesson not found", "LESSON_NOT_FOUND")
return
}
// For now, return the existing lesson
SuccessResponse(c, gin.H{
"lessonId": lessonID,
"status": "regeneration_pending",
"message": "AI lesson regeneration will be available in a future version",
})
}
// ---------------------------------------------------------------------------
// Video Generation
// ---------------------------------------------------------------------------
// GenerateVideos initiates video generation for all lessons in a course
func (h *AcademyHandler) GenerateVideos(c *gin.Context) {
courseID := c.Param("id")
_, err := h.academyStore.GetCourse(courseID)
if err != nil {
ErrorResponse(c, http.StatusNotFound, "Course not found", "COURSE_NOT_FOUND")
return
}
lessons := h.academyStore.ListLessons(courseID)
lessonStatuses := make([]LessonVideoStatus, 0, len(lessons))
for _, l := range lessons {
if l.Type == "text" || l.Type == "video" {
lessonStatuses = append(lessonStatuses, LessonVideoStatus{
LessonID: l.ID,
Status: "pending",
})
}
}
SuccessResponse(c, VideoStatusResponse{
CourseID: courseID,
Status: "pending",
Lessons: lessonStatuses,
})
}
// GetVideoStatus returns the video generation status for a course
func (h *AcademyHandler) GetVideoStatus(c *gin.Context) {
courseID := c.Param("id")
_, err := h.academyStore.GetCourse(courseID)
if err != nil {
ErrorResponse(c, http.StatusNotFound, "Course not found", "COURSE_NOT_FOUND")
return
}
lessons := h.academyStore.ListLessons(courseID)
lessonStatuses := make([]LessonVideoStatus, 0, len(lessons))
for _, l := range lessons {
status := LessonVideoStatus{
LessonID: l.ID,
Status: "not_started",
VideoURL: l.VideoURL,
AudioURL: l.AudioURL,
}
if l.VideoURL != "" {
status.Status = "completed"
}
lessonStatuses = append(lessonStatuses, status)
}
overallStatus := "not_started"
hasCompleted := false
hasPending := false
for _, s := range lessonStatuses {
if s.Status == "completed" {
hasCompleted = true
} else {
hasPending = true
}
}
if hasCompleted && !hasPending {
overallStatus = "completed"
} else if hasCompleted && hasPending {
overallStatus = "processing"
}
SuccessResponse(c, VideoStatusResponse{
CourseID: courseID,
Status: overallStatus,
Lessons: lessonStatuses,
})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func (h *AcademyHandler) buildLessonsForCourse(courseID string) []AcademyLesson {
lessonRows := h.academyStore.ListLessons(courseID)
lessons := make([]AcademyLesson, 0, len(lessonRows))
for _, lr := range lessonRows {
var questions []AcademyQuizQuestion
if lr.Type == "quiz" {
qRows := h.academyStore.ListQuizQuestions(lr.ID)
questions = make([]AcademyQuizQuestion, 0, len(qRows))
for _, qr := range qRows {
questions = append(questions, quizQuestionRowToResponse(qr))
}
}
lessons = append(lessons, lessonRowToResponse(lr, questions))
}
return lessons
}
func courseRowToResponse(row *db.AcademyCourseRow, lessons []AcademyLesson) AcademyCourse {
return AcademyCourse{
ID: row.ID,
TenantID: row.TenantID,
Title: row.Title,
Description: row.Description,
Category: row.Category,
PassingScore: row.PassingScore,
DurationMinutes: row.DurationMinutes,
RequiredForRoles: row.RequiredForRoles,
Status: row.Status,
Lessons: lessons,
CreatedAt: row.CreatedAt.Format(time.RFC3339),
UpdatedAt: row.UpdatedAt.Format(time.RFC3339),
}
}
func lessonRowToResponse(row *db.AcademyLessonRow, questions []AcademyQuizQuestion) AcademyLesson {
return AcademyLesson{
ID: row.ID,
CourseID: row.CourseID,
Title: row.Title,
Type: row.Type,
ContentMarkdown: row.ContentMarkdown,
VideoURL: row.VideoURL,
AudioURL: row.AudioURL,
Order: row.SortOrder,
DurationMinutes: row.DurationMinutes,
QuizQuestions: questions,
}
}
func quizQuestionRowToResponse(row *db.AcademyQuizQuestionRow) AcademyQuizQuestion {
return AcademyQuizQuestion{
ID: row.ID,
LessonID: row.LessonID,
Question: row.Question,
Options: row.Options,
CorrectOptionIndex: row.CorrectOptionIndex,
Explanation: row.Explanation,
Order: row.SortOrder,
}
}
func enrollmentRowToResponse(row *db.AcademyEnrollmentRow) AcademyEnrollment {
e := AcademyEnrollment{
ID: row.ID,
TenantID: row.TenantID,
CourseID: row.CourseID,
UserID: row.UserID,
UserName: row.UserName,
UserEmail: row.UserEmail,
Status: row.Status,
Progress: row.Progress,
StartedAt: row.StartedAt.Format(time.RFC3339),
CertificateID: row.CertificateID,
Deadline: row.Deadline.Format(time.RFC3339),
CreatedAt: row.CreatedAt.Format(time.RFC3339),
UpdatedAt: row.UpdatedAt.Format(time.RFC3339),
}
if row.CompletedAt != nil {
e.CompletedAt = row.CompletedAt.Format(time.RFC3339)
}
return e
}
func certificateRowToResponse(row *db.AcademyCertificateRow) AcademyCertificate {
return AcademyCertificate{
ID: row.ID,
TenantID: row.TenantID,
EnrollmentID: row.EnrollmentID,
CourseID: row.CourseID,
UserID: row.UserID,
UserName: row.UserName,
CourseName: row.CourseName,
Score: row.Score,
IssuedAt: row.IssuedAt.Format(time.RFC3339),
ValidUntil: row.ValidUntil.Format(time.RFC3339),
PdfURL: row.PdfURL,
}
}
// ---------------------------------------------------------------------------
// Mock Course Generator (used when LLM is not available)
// ---------------------------------------------------------------------------
func (h *AcademyHandler) generateMockCourse(req GenerateCourseRequest) AcademyCourse {
switch req.Category {
case "dsgvo_basics":
return h.mockDSGVOCourse(req)
case "it_security":
return h.mockITSecurityCourse(req)
case "ai_literacy":
return h.mockAILiteracyCourse(req)
case "whistleblower_protection":
return h.mockWhistleblowerCourse(req)
default:
return h.mockDSGVOCourse(req)
}
}
func (h *AcademyHandler) mockDSGVOCourse(req GenerateCourseRequest) AcademyCourse {
return AcademyCourse{
Title: "DSGVO-Grundlagen fuer Mitarbeiter",
Description: "Umfassende Einfuehrung in die Datenschutz-Grundverordnung. Vermittelt die wichtigsten Grundsaetze des Datenschutzes, Betroffenenrechte und die korrekte Handhabung personenbezogener Daten.",
DurationMinutes: 90,
Lessons: []AcademyLesson{
{
Title: "Was ist die DSGVO?",
Type: "text",
Order: 1,
DurationMinutes: 15,
ContentMarkdown: "# Was ist die DSGVO?\n\nDie Datenschutz-Grundverordnung (DSGVO) ist eine Verordnung der EU, die seit dem 25. Mai 2018 gilt. Sie schuetzt die Grundrechte natuerlicher Personen bei der Verarbeitung personenbezogener Daten.\n\n## Warum ist die DSGVO wichtig?\n\n- **Einheitlicher Datenschutz** in der gesamten EU\n- **Hohe Bussgelder** bei Verstoessen (bis 20 Mio. EUR oder 4% des Jahresumsatzes)\n- **Staerkung der Betroffenenrechte** (Auskunft, Loeschung, Widerspruch)\n\n## Zentrale Begriffe\n\n- **Personenbezogene Daten**: Alle Informationen, die sich auf eine identifizierte oder identifizierbare Person beziehen\n- **Verantwortlicher**: Die Stelle, die ueber Zweck und Mittel der Verarbeitung entscheidet\n- **Auftragsverarbeiter**: Verarbeitet Daten im Auftrag des Verantwortlichen",
},
{
Title: "Die 7 Grundsaetze der DSGVO",
Type: "text",
Order: 2,
DurationMinutes: 20,
ContentMarkdown: "# Die 7 Grundsaetze der DSGVO (Art. 5)\n\n## 1. Rechtmaessigkeit, Verarbeitung nach Treu und Glauben, Transparenz\nPersonenbezogene Daten muessen auf rechtmaessige Weise verarbeitet werden.\n\n## 2. Zweckbindung\nDaten duerfen nur fuer festgelegte, eindeutige und legitime Zwecke erhoben werden.\n\n## 3. Datenminimierung\nEs duerfen nur Daten erhoben werden, die fuer den Zweck erforderlich sind.\n\n## 4. Richtigkeit\nDaten muessen sachlich richtig und auf dem neuesten Stand sein.\n\n## 5. Speicherbegrenzung\nDaten duerfen nur so lange gespeichert werden, wie es fuer den Zweck erforderlich ist.\n\n## 6. Integritaet und Vertraulichkeit\nDaten muessen vor unbefugtem Zugriff geschuetzt werden.\n\n## 7. Rechenschaftspflicht\nDer Verantwortliche muss die Einhaltung der Grundsaetze nachweisen koennen.",
},
{
Title: "Betroffenenrechte (Art. 15-22 DSGVO)",
Type: "text",
Order: 3,
DurationMinutes: 20,
ContentMarkdown: "# Betroffenenrechte\n\n## Recht auf Auskunft (Art. 15)\nJede Person hat das Recht zu erfahren, ob und welche Daten ueber sie verarbeitet werden.\n\n## Recht auf Berichtigung (Art. 16)\nUnrichtige Daten muessen berichtigt werden.\n\n## Recht auf Loeschung (Art. 17)\nDas 'Recht auf Vergessenwerden' ermoeglicht die Loeschung personenbezogener Daten.\n\n## Recht auf Einschraenkung (Art. 18)\nBetroffene koennen die Verarbeitung einschraenken lassen.\n\n## Recht auf Datenuebertragbarkeit (Art. 20)\nDaten muessen in einem maschinenlesbaren Format bereitgestellt werden.\n\n## Widerspruchsrecht (Art. 21)\nBetroffene koennen der Verarbeitung widersprechen.",
},
{
Title: "Datenschutz im Arbeitsalltag",
Type: "text",
Order: 4,
DurationMinutes: 15,
ContentMarkdown: "# Datenschutz im Arbeitsalltag\n\n## E-Mails\n- Keine personenbezogenen Daten unverschluesselt versenden\n- BCC statt CC bei Massenversand\n- Vorsicht bei Anhangen\n\n## Bildschirmsperre\n- Computer bei Abwesenheit sperren (Win+L / Cmd+Ctrl+Q)\n- Automatische Sperre nach 5 Minuten\n\n## Clean Desk Policy\n- Keine sensiblen Dokumente offen liegen lassen\n- Aktenvernichter fuer Papierdokumente\n\n## Homeoffice\n- VPN nutzen\n- Kein oeffentliches WLAN fuer Firmendaten\n- Bildschirm vor Mitlesern schuetzen\n\n## Datenpannen melden\n- **Sofort** den Datenschutzbeauftragten informieren\n- Innerhalb von 72 Stunden an die Aufsichtsbehoerde\n- Dokumentation der Panne",
},
{
Title: "Wissenstest: DSGVO-Grundlagen",
Type: "quiz",
Order: 5,
DurationMinutes: 20,
QuizQuestions: []AcademyQuizQuestion{
{
Question: "Seit wann gilt die DSGVO?",
Options: []string{"1. Januar 2016", "25. Mai 2018", "1. Januar 2020", "25. Mai 2020"},
CorrectOptionIndex: 1,
Explanation: "Die DSGVO gilt seit dem 25. Mai 2018 in allen EU-Mitgliedstaaten.",
Order: 1,
},
{
Question: "Was sind personenbezogene Daten?",
Options: []string{"Nur Name und Adresse", "Alle Informationen, die sich auf eine identifizierbare Person beziehen", "Nur digitale Daten", "Nur sensible Gesundheitsdaten"},
CorrectOptionIndex: 1,
Explanation: "Personenbezogene Daten umfassen alle Informationen, die sich auf eine identifizierte oder identifizierbare natuerliche Person beziehen.",
Order: 2,
},
{
Question: "Wie hoch kann das Bussgeld bei DSGVO-Verstoessen maximal sein?",
Options: []string{"1 Mio. EUR", "5 Mio. EUR", "10 Mio. EUR oder 2% des Jahresumsatzes", "20 Mio. EUR oder 4% des Jahresumsatzes"},
CorrectOptionIndex: 3,
Explanation: "Bei schwerwiegenden Verstoessen koennen Bussgelder von bis zu 20 Mio. EUR oder 4% des weltweiten Jahresumsatzes verhaengt werden.",
Order: 3,
},
{
Question: "Was bedeutet das Prinzip der Datenminimierung?",
Options: []string{"Alle Daten muessen verschluesselt werden", "Es duerfen nur fuer den Zweck erforderliche Daten erhoben werden", "Daten muessen nach 30 Tagen geloescht werden", "Nur Administratoren duerfen auf Daten zugreifen"},
CorrectOptionIndex: 1,
Explanation: "Datenminimierung bedeutet, dass nur die fuer den jeweiligen Zweck erforderlichen Daten erhoben und verarbeitet werden duerfen.",
Order: 4,
},
{
Question: "Innerhalb welcher Frist muss eine Datenpanne der Aufsichtsbehoerde gemeldet werden?",
Options: []string{"24 Stunden", "48 Stunden", "72 Stunden", "7 Tage"},
CorrectOptionIndex: 2,
Explanation: "Gemaess Art. 33 DSGVO muss eine Datenpanne innerhalb von 72 Stunden nach Bekanntwerden der Aufsichtsbehoerde gemeldet werden.",
Order: 5,
},
},
},
},
}
}
func (h *AcademyHandler) mockITSecurityCourse(req GenerateCourseRequest) AcademyCourse {
return AcademyCourse{
Title: "IT-Sicherheit & Cybersecurity Awareness",
Description: "Sensibilisierung fuer IT-Sicherheitsrisiken und Best Practices im Umgang mit Phishing, Passwoertern und Social Engineering.",
DurationMinutes: 60,
Lessons: []AcademyLesson{
{Title: "Phishing erkennen und vermeiden", Type: "text", Order: 1, DurationMinutes: 15,
ContentMarkdown: "# Phishing erkennen\n\n## Typische Merkmale\n- Dringlichkeit ('Ihr Konto wird gesperrt!')\n- Unbekannter Absender\n- Verdaechtige Links\n- Rechtschreibfehler\n\n## Was tun bei Verdacht?\n1. Link NICHT anklicken\n2. Anhang NICHT oeffnen\n3. IT-Sicherheit informieren"},
{Title: "Sichere Passwoerter und MFA", Type: "text", Order: 2, DurationMinutes: 15,
ContentMarkdown: "# Sichere Passwoerter\n\n## Regeln\n- Mindestens 12 Zeichen\n- Gross-/Kleinbuchstaben, Zahlen, Sonderzeichen\n- Fuer jeden Dienst ein eigenes Passwort\n- Passwort-Manager verwenden\n\n## Multi-Faktor-Authentifizierung\n- Immer aktivieren wenn moeglich\n- App-basiert (z.B. Microsoft Authenticator) bevorzugen"},
{Title: "Social Engineering", Type: "text", Order: 3, DurationMinutes: 15,
ContentMarkdown: "# Social Engineering\n\nAngreifer nutzen menschliche Schwaechen aus.\n\n## Methoden\n- **Pretexting**: Falsche Identitaet vortaeuschen\n- **Tailgating**: Unbefugter Zutritt durch Hinterherfolgen\n- **CEO Fraud**: Gefaelschte Anweisungen vom Vorgesetzten\n\n## Schutz\n- Identitaet immer verifizieren\n- Bei Unsicherheit nachfragen"},
{Title: "Wissenstest: IT-Sicherheit", Type: "quiz", Order: 4, DurationMinutes: 15,
QuizQuestions: []AcademyQuizQuestion{
{Question: "Was ist ein typisches Merkmal einer Phishing-E-Mail?", Options: []string{"Professionelles Design", "Kuenstliche Dringlichkeit", "Bekannter Absender", "Kurzer Text"}, CorrectOptionIndex: 1, Explanation: "Phishing-Mails erzeugen oft kuenstliche Dringlichkeit.", Order: 1},
{Question: "Wie lang sollte ein sicheres Passwort mindestens sein?", Options: []string{"6 Zeichen", "8 Zeichen", "10 Zeichen", "12 Zeichen"}, CorrectOptionIndex: 3, Explanation: "Mindestens 12 Zeichen werden empfohlen.", Order: 2},
{Question: "Was ist CEO Fraud?", Options: []string{"Hacker-Angriff auf Server", "Gefaelschte Anweisung vom Vorgesetzten", "Virus in E-Mail-Anhang", "DDoS-Attacke"}, CorrectOptionIndex: 1, Explanation: "CEO Fraud ist eine Social-Engineering-Methode mit gefaelschten Anweisungen.", Order: 3},
}},
},
}
}
func (h *AcademyHandler) mockAILiteracyCourse(req GenerateCourseRequest) AcademyCourse {
return AcademyCourse{
Title: "AI Literacy - Sicherer Umgang mit KI",
Description: "Grundlagen kuenstlicher Intelligenz, EU AI Act und verantwortungsvoller Einsatz von KI-Werkzeugen im Unternehmen.",
DurationMinutes: 75,
Lessons: []AcademyLesson{
{Title: "Was ist Kuenstliche Intelligenz?", Type: "text", Order: 1, DurationMinutes: 15,
ContentMarkdown: "# Was ist KI?\n\nKuenstliche Intelligenz (KI) bezeichnet Systeme, die menschenaehnliche kognitive Faehigkeiten zeigen.\n\n## Arten von KI\n- **Machine Learning**: Lernt aus Daten\n- **Deep Learning**: Neuronale Netze\n- **Generative AI**: Erstellt neue Inhalte (Text, Bild)\n- **LLMs**: Large Language Models wie ChatGPT"},
{Title: "Der EU AI Act", Type: "text", Order: 2, DurationMinutes: 20,
ContentMarkdown: "# EU AI Act\n\n## Risikoklassen\n- **Unakzeptabel**: Social Scoring, Manipulation\n- **Hochrisiko**: Bildung, HR, Kritische Infrastruktur\n- **Begrenzt**: Chatbots, Empfehlungssysteme\n- **Minimal**: Spam-Filter\n\n## Art. 4: AI Literacy Pflicht\nAlle Mitarbeiter, die KI-Systeme nutzen, muessen geschult werden."},
{Title: "KI sicher im Unternehmen nutzen", Type: "text", Order: 3, DurationMinutes: 20,
ContentMarkdown: "# KI sicher nutzen\n\n## Dos\n- Ergebnisse immer pruefen\n- Keine vertraulichen Daten eingeben\n- Firmenpolicies beachten\n\n## Don'ts\n- Blindes Vertrauen in KI-Ergebnisse\n- Personenbezogene Daten in externe KI-Tools\n- KI-generierte Inhalte ohne Pruefung veroeffentlichen"},
{Title: "Wissenstest: AI Literacy", Type: "quiz", Order: 4, DurationMinutes: 20,
QuizQuestions: []AcademyQuizQuestion{
{Question: "Was verlangt Art. 4 des EU AI Acts?", Options: []string{"Verbot aller KI-Systeme", "AI Literacy Schulung fuer KI-Nutzer", "Nur Open-Source KI erlaubt", "KI nur in der IT-Abteilung"}, CorrectOptionIndex: 1, Explanation: "Art. 4 EU AI Act fordert AI Literacy fuer alle Mitarbeiter, die KI-Systeme nutzen.", Order: 1},
{Question: "Duerfen vertrauliche Firmendaten in externe KI-Tools eingegeben werden?", Options: []string{"Ja, immer", "Nur in ChatGPT", "Nein, grundsaetzlich nicht", "Nur mit VPN"}, CorrectOptionIndex: 2, Explanation: "Vertrauliche Daten duerfen nicht in externe KI-Tools eingegeben werden.", Order: 2},
}},
},
}
}
func (h *AcademyHandler) mockWhistleblowerCourse(req GenerateCourseRequest) AcademyCourse {
return AcademyCourse{
Title: "Hinweisgeberschutz (HinSchG)",
Description: "Einführung in das Hinweisgeberschutzgesetz, interne Meldewege und Schutz von Whistleblowern.",
DurationMinutes: 45,
Lessons: []AcademyLesson{
{Title: "Das Hinweisgeberschutzgesetz", Type: "text", Order: 1, DurationMinutes: 15,
ContentMarkdown: "# Hinweisgeberschutzgesetz (HinSchG)\n\nSeit Juli 2023 muessen Unternehmen ab 50 Mitarbeitern interne Meldestellen einrichten.\n\n## Was ist geschuetzt?\n- Meldungen ueber Rechtsverstoesse\n- Verstoesse gegen EU-Recht\n- Straftaten und Ordnungswidrigkeiten"},
{Title: "Interne Meldewege", Type: "text", Order: 2, DurationMinutes: 15,
ContentMarkdown: "# Interne Meldewege\n\n## Wie melde ich einen Verstoss?\n1. **Interne Meldestelle** (bevorzugt)\n2. **Externe Meldestelle** (BfJ)\n3. **Offenlegung** (nur als letztes Mittel)\n\n## Schutz fuer Hinweisgeber\n- Kuendigungsschutz\n- Keine Benachteiligung\n- Vertraulichkeit"},
{Title: "Wissenstest: Hinweisgeberschutz", Type: "quiz", Order: 3, DurationMinutes: 15,
QuizQuestions: []AcademyQuizQuestion{
{Question: "Ab wie vielen Mitarbeitern muessen Unternehmen eine Meldestelle einrichten?", Options: []string{"10", "25", "50", "250"}, CorrectOptionIndex: 2, Explanation: "Unternehmen ab 50 Beschaeftigten muessen eine interne Meldestelle einrichten.", Order: 1},
{Question: "Welche Meldung ist NICHT durch das HinSchG geschuetzt?", Options: []string{"Straftaten", "Verstoesse gegen EU-Recht", "Persoenliche Beschwerden ueber Kollegen", "Umweltverstoesse"}, CorrectOptionIndex: 2, Explanation: "Persoenliche Konflikte fallen nicht unter das HinSchG.", Order: 2},
}},
},
}
}

View File

@@ -0,0 +1,209 @@
package api
// Academy Course models
// AcademyCourse represents a training course in the Academy module
type AcademyCourse struct {
ID string `json:"id"`
TenantID string `json:"tenantId,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
PassingScore int `json:"passingScore"`
DurationMinutes int `json:"durationMinutes"`
RequiredForRoles []string `json:"requiredForRoles"`
Status string `json:"status"`
Lessons []AcademyLesson `json:"lessons"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// AcademyLesson represents a single lesson within a course
type AcademyLesson struct {
ID string `json:"id"`
CourseID string `json:"courseId"`
Title string `json:"title"`
Type string `json:"type"` // video, text, quiz
ContentMarkdown string `json:"contentMarkdown"`
VideoURL string `json:"videoUrl,omitempty"`
AudioURL string `json:"audioUrl,omitempty"`
Order int `json:"order"`
DurationMinutes int `json:"durationMinutes"`
QuizQuestions []AcademyQuizQuestion `json:"quizQuestions,omitempty"`
}
// AcademyQuizQuestion represents a single quiz question within a lesson
type AcademyQuizQuestion struct {
ID string `json:"id"`
LessonID string `json:"lessonId"`
Question string `json:"question"`
Options []string `json:"options"`
CorrectOptionIndex int `json:"correctOptionIndex"`
Explanation string `json:"explanation"`
Order int `json:"order"`
}
// AcademyEnrollment represents a user's enrollment in a course
type AcademyEnrollment struct {
ID string `json:"id"`
TenantID string `json:"tenantId,omitempty"`
CourseID string `json:"courseId"`
UserID string `json:"userId"`
UserName string `json:"userName"`
UserEmail string `json:"userEmail"`
Status string `json:"status"` // not_started, in_progress, completed, expired
Progress int `json:"progress"` // 0-100
StartedAt string `json:"startedAt"`
CompletedAt string `json:"completedAt,omitempty"`
CertificateID string `json:"certificateId,omitempty"`
Deadline string `json:"deadline"`
CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
// AcademyCertificate represents a certificate issued upon course completion
type AcademyCertificate struct {
ID string `json:"id"`
TenantID string `json:"tenantId,omitempty"`
EnrollmentID string `json:"enrollmentId"`
CourseID string `json:"courseId"`
UserID string `json:"userId"`
UserName string `json:"userName"`
CourseName string `json:"courseName"`
Score int `json:"score"`
IssuedAt string `json:"issuedAt"`
ValidUntil string `json:"validUntil"`
PdfURL string `json:"pdfUrl,omitempty"`
}
// AcademyLessonProgress tracks a user's progress through a single lesson
type AcademyLessonProgress struct {
ID string `json:"id"`
EnrollmentID string `json:"enrollmentId"`
LessonID string `json:"lessonId"`
Completed bool `json:"completed"`
QuizScore *int `json:"quizScore,omitempty"`
CompletedAt string `json:"completedAt,omitempty"`
}
// AcademyStatistics provides aggregate statistics for the Academy module
type AcademyStatistics struct {
TotalCourses int `json:"totalCourses"`
TotalEnrollments int `json:"totalEnrollments"`
CompletionRate int `json:"completionRate"`
OverdueCount int `json:"overdueCount"`
ByCategory map[string]int `json:"byCategory"`
ByStatus map[string]int `json:"byStatus"`
}
// Request types
// CreateCourseRequest is the request body for creating a new course
type CreateCourseRequest struct {
TenantID string `json:"tenantId" binding:"required"`
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Category string `json:"category" binding:"required"`
DurationMinutes int `json:"durationMinutes"`
RequiredForRoles []string `json:"requiredForRoles"`
PassingScore int `json:"passingScore"`
Lessons []CreateLessonRequest `json:"lessons"`
}
// CreateLessonRequest is the request body for creating a lesson within a course
type CreateLessonRequest struct {
Title string `json:"title" binding:"required"`
Type string `json:"type" binding:"required"`
ContentMarkdown string `json:"contentMarkdown"`
VideoURL string `json:"videoUrl"`
Order int `json:"order"`
DurationMinutes int `json:"durationMinutes"`
QuizQuestions []CreateQuizQuestionRequest `json:"quizQuestions"`
}
// CreateQuizQuestionRequest is the request body for creating a quiz question
type CreateQuizQuestionRequest struct {
Question string `json:"question" binding:"required"`
Options []string `json:"options" binding:"required"`
CorrectOptionIndex int `json:"correctOptionIndex"`
Explanation string `json:"explanation"`
Order int `json:"order"`
}
// UpdateCourseRequest is the request body for updating an existing course
type UpdateCourseRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Category *string `json:"category"`
DurationMinutes *int `json:"durationMinutes"`
RequiredForRoles []string `json:"requiredForRoles"`
PassingScore *int `json:"passingScore"`
}
// EnrollUserRequest is the request body for enrolling a user in a course
type EnrollUserRequest struct {
TenantID string `json:"tenantId" binding:"required"`
CourseID string `json:"courseId" binding:"required"`
UserID string `json:"userId" binding:"required"`
UserName string `json:"userName" binding:"required"`
UserEmail string `json:"userEmail" binding:"required"`
Deadline string `json:"deadline" binding:"required"`
}
// UpdateProgressRequest is the request body for updating enrollment progress
type UpdateProgressRequest struct {
Progress int `json:"progress"`
LessonID string `json:"lessonId"`
}
// SubmitQuizRequest is the request body for submitting quiz answers
type SubmitQuizRequest struct {
Answers []int `json:"answers" binding:"required"`
}
// SubmitQuizResponse is the response for a quiz submission
type SubmitQuizResponse struct {
Score int `json:"score"`
Passed bool `json:"passed"`
CorrectAnswers int `json:"correctAnswers"`
TotalQuestions int `json:"totalQuestions"`
Results []QuizQuestionResult `json:"results"`
}
// QuizQuestionResult represents the result of a single quiz question
type QuizQuestionResult struct {
QuestionID string `json:"questionId"`
Correct bool `json:"correct"`
Explanation string `json:"explanation"`
}
// GenerateCourseRequest is the request body for AI-generating a course
type GenerateCourseRequest struct {
TenantID string `json:"tenantId" binding:"required"`
Topic string `json:"topic" binding:"required"`
Category string `json:"category" binding:"required"`
TargetGroup string `json:"targetGroup"`
Language string `json:"language"`
UseRAG bool `json:"useRag"`
RAGQuery string `json:"ragQuery"`
}
// GenerateVideosRequest is the request body for generating lesson videos
type GenerateVideosRequest struct {
TenantID string `json:"tenantId" binding:"required"`
}
// VideoStatusResponse represents the video generation status for a course
type VideoStatusResponse struct {
CourseID string `json:"courseId"`
Status string `json:"status"` // pending, processing, completed, failed
Lessons []LessonVideoStatus `json:"lessons"`
}
// LessonVideoStatus represents the video generation status for a single lesson
type LessonVideoStatus struct {
LessonID string `json:"lessonId"`
Status string `json:"status"`
VideoURL string `json:"videoUrl,omitempty"`
AudioURL string `json:"audioUrl,omitempty"`
}

View File

@@ -31,15 +31,15 @@ func (h *GenerateHandler) GenerateDSFA(c *gin.Context) {
}
// Get RAG context if requested
var ragSources []SearchResult
var ragSources []llm.SearchResult
if req.UseRAG && h.ragService != nil {
query := req.RAGQuery
if query == "" {
query = "DSFA Datenschutz-Folgenabschätzung Anforderungen"
query = "DSFA Datenschutz-Folgenabschaetzung Anforderungen"
}
results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "regulation:DSGVO")
for _, r := range results {
ragSources = append(ragSources, SearchResult{
ragSources = append(ragSources, llm.SearchResult{
ID: r.ID,
Content: r.Content,
Source: r.Source,
@@ -62,7 +62,7 @@ func (h *GenerateHandler) GenerateDSFA(c *gin.Context) {
GeneratedAt: now(),
Model: h.llmService.GetModel(),
TokensUsed: tokensUsed,
RAGSources: ragSources,
RAGSources: convertLLMSources(ragSources),
Confidence: 0.85,
})
}
@@ -76,15 +76,15 @@ func (h *GenerateHandler) GenerateTOM(c *gin.Context) {
}
// Get RAG context if requested
var ragSources []SearchResult
var llmRagSources []llm.SearchResult
if req.UseRAG && h.ragService != nil {
query := req.RAGQuery
if query == "" {
query = "technische organisatorische Maßnahmen TOM Datenschutz"
query = "technische organisatorische Massnahmen TOM Datenschutz"
}
results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "")
for _, r := range results {
ragSources = append(ragSources, SearchResult{
llmRagSources = append(llmRagSources, llm.SearchResult{
ID: r.ID,
Content: r.Content,
Source: r.Source,
@@ -95,7 +95,7 @@ func (h *GenerateHandler) GenerateTOM(c *gin.Context) {
}
// Generate TOM content
content, tokensUsed, err := h.llmService.GenerateTOM(c.Request.Context(), req.Context, ragSources)
content, tokensUsed, err := h.llmService.GenerateTOM(c.Request.Context(), req.Context, llmRagSources)
if err != nil {
content = h.getMockTOM(req.Context)
tokensUsed = 0
@@ -106,7 +106,7 @@ func (h *GenerateHandler) GenerateTOM(c *gin.Context) {
GeneratedAt: now(),
Model: h.llmService.GetModel(),
TokensUsed: tokensUsed,
RAGSources: ragSources,
RAGSources: convertLLMSources(llmRagSources),
Confidence: 0.82,
})
}
@@ -120,7 +120,7 @@ func (h *GenerateHandler) GenerateVVT(c *gin.Context) {
}
// Get RAG context if requested
var ragSources []SearchResult
var llmRagSources []llm.SearchResult
if req.UseRAG && h.ragService != nil {
query := req.RAGQuery
if query == "" {
@@ -128,7 +128,7 @@ func (h *GenerateHandler) GenerateVVT(c *gin.Context) {
}
results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "regulation:DSGVO")
for _, r := range results {
ragSources = append(ragSources, SearchResult{
llmRagSources = append(llmRagSources, llm.SearchResult{
ID: r.ID,
Content: r.Content,
Source: r.Source,
@@ -139,7 +139,7 @@ func (h *GenerateHandler) GenerateVVT(c *gin.Context) {
}
// Generate VVT content
content, tokensUsed, err := h.llmService.GenerateVVT(c.Request.Context(), req.Context, ragSources)
content, tokensUsed, err := h.llmService.GenerateVVT(c.Request.Context(), req.Context, llmRagSources)
if err != nil {
content = h.getMockVVT(req.Context)
tokensUsed = 0
@@ -150,7 +150,7 @@ func (h *GenerateHandler) GenerateVVT(c *gin.Context) {
GeneratedAt: now(),
Model: h.llmService.GetModel(),
TokensUsed: tokensUsed,
RAGSources: ragSources,
RAGSources: convertLLMSources(llmRagSources),
Confidence: 0.88,
})
}
@@ -164,7 +164,7 @@ func (h *GenerateHandler) GenerateGutachten(c *gin.Context) {
}
// Get RAG context if requested
var ragSources []SearchResult
var llmRagSources []llm.SearchResult
if req.UseRAG && h.ragService != nil {
query := req.RAGQuery
if query == "" {
@@ -172,7 +172,7 @@ func (h *GenerateHandler) GenerateGutachten(c *gin.Context) {
}
results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "")
for _, r := range results {
ragSources = append(ragSources, SearchResult{
llmRagSources = append(llmRagSources, llm.SearchResult{
ID: r.ID,
Content: r.Content,
Source: r.Source,
@@ -183,7 +183,7 @@ func (h *GenerateHandler) GenerateGutachten(c *gin.Context) {
}
// Generate Gutachten content
content, tokensUsed, err := h.llmService.GenerateGutachten(c.Request.Context(), req.Context, ragSources)
content, tokensUsed, err := h.llmService.GenerateGutachten(c.Request.Context(), req.Context, llmRagSources)
if err != nil {
content = h.getMockGutachten(req.Context)
tokensUsed = 0
@@ -194,7 +194,7 @@ func (h *GenerateHandler) GenerateGutachten(c *gin.Context) {
GeneratedAt: now(),
Model: h.llmService.GetModel(),
TokensUsed: tokensUsed,
RAGSources: ragSources,
RAGSources: convertLLMSources(llmRagSources),
Confidence: 0.80,
})
}
@@ -363,3 +363,21 @@ Das geprüfte KI-System erfüllt die wesentlichen Anforderungen der DSGVO und de
Erstellt am: ${new Date().toISOString()}
`
}
// convertLLMSources converts llm.SearchResult to api.SearchResult for the response
func convertLLMSources(sources []llm.SearchResult) []SearchResult {
if sources == nil {
return nil
}
result := make([]SearchResult, len(sources))
for i, s := range sources {
result[i] = SearchResult{
ID: s.ID,
Content: s.Content,
Source: s.Source,
Score: s.Score,
Metadata: s.Metadata,
}
}
return result
}

View File

@@ -0,0 +1,681 @@
package db
import (
"fmt"
"sort"
"strings"
"sync"
"time"
)
// AcademyMemStore provides in-memory storage for academy data
type AcademyMemStore struct {
mu sync.RWMutex
courses map[string]*AcademyCourseRow
lessons map[string]*AcademyLessonRow
quizQuestions map[string]*AcademyQuizQuestionRow
enrollments map[string]*AcademyEnrollmentRow
certificates map[string]*AcademyCertificateRow
lessonProgress map[string]*AcademyLessonProgressRow
}
// Row types matching the DB schema
type AcademyCourseRow struct {
ID string
TenantID string
Title string
Description string
Category string
PassingScore int
DurationMinutes int
RequiredForRoles []string
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
type AcademyLessonRow struct {
ID string
CourseID string
Title string
Type string
ContentMarkdown string
VideoURL string
AudioURL string
SortOrder int
DurationMinutes int
CreatedAt time.Time
UpdatedAt time.Time
}
type AcademyQuizQuestionRow struct {
ID string
LessonID string
Question string
Options []string
CorrectOptionIndex int
Explanation string
SortOrder int
CreatedAt time.Time
}
type AcademyEnrollmentRow struct {
ID string
TenantID string
CourseID string
UserID string
UserName string
UserEmail string
Status string
Progress int
StartedAt time.Time
CompletedAt *time.Time
CertificateID string
Deadline time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type AcademyCertificateRow struct {
ID string
TenantID string
EnrollmentID string
CourseID string
UserID string
UserName string
CourseName string
Score int
IssuedAt time.Time
ValidUntil time.Time
PdfURL string
}
type AcademyLessonProgressRow struct {
ID string
EnrollmentID string
LessonID string
Completed bool
QuizScore *int
CompletedAt *time.Time
}
type AcademyStatisticsRow struct {
TotalCourses int
TotalEnrollments int
CompletionRate float64
OverdueCount int
ByCategory map[string]int
ByStatus map[string]int
}
func NewAcademyMemStore() *AcademyMemStore {
return &AcademyMemStore{
courses: make(map[string]*AcademyCourseRow),
lessons: make(map[string]*AcademyLessonRow),
quizQuestions: make(map[string]*AcademyQuizQuestionRow),
enrollments: make(map[string]*AcademyEnrollmentRow),
certificates: make(map[string]*AcademyCertificateRow),
lessonProgress: make(map[string]*AcademyLessonProgressRow),
}
}
// generateID creates a simple unique ID
func generateID() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
// ---------------------------------------------------------------------------
// Course CRUD
// ---------------------------------------------------------------------------
// ListCourses returns all courses for a tenant, sorted by UpdatedAt DESC.
func (s *AcademyMemStore) ListCourses(tenantID string) []*AcademyCourseRow {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*AcademyCourseRow
for _, c := range s.courses {
if c.TenantID == tenantID {
result = append(result, c)
}
}
sort.Slice(result, func(i, j int) bool {
return result[i].UpdatedAt.After(result[j].UpdatedAt)
})
return result
}
// GetCourse retrieves a single course by ID.
func (s *AcademyMemStore) GetCourse(id string) (*AcademyCourseRow, error) {
s.mu.RLock()
defer s.mu.RUnlock()
c, ok := s.courses[id]
if !ok {
return nil, fmt.Errorf("course not found: %s", id)
}
return c, nil
}
// CreateCourse inserts a new course with auto-generated ID and timestamps.
func (s *AcademyMemStore) CreateCourse(row *AcademyCourseRow) *AcademyCourseRow {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
row.ID = generateID()
row.CreatedAt = now
row.UpdatedAt = now
s.courses[row.ID] = row
return row
}
// UpdateCourse partially updates a course. Supported keys: Title, Description,
// Category, PassingScore, DurationMinutes, RequiredForRoles, Status.
func (s *AcademyMemStore) UpdateCourse(id string, updates map[string]interface{}) (*AcademyCourseRow, error) {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.courses[id]
if !ok {
return nil, fmt.Errorf("course not found: %s", id)
}
for k, v := range updates {
switch strings.ToLower(k) {
case "title":
if val, ok := v.(string); ok {
c.Title = val
}
case "description":
if val, ok := v.(string); ok {
c.Description = val
}
case "category":
if val, ok := v.(string); ok {
c.Category = val
}
case "passingscore", "passing_score":
switch val := v.(type) {
case int:
c.PassingScore = val
case float64:
c.PassingScore = int(val)
}
case "durationminutes", "duration_minutes":
switch val := v.(type) {
case int:
c.DurationMinutes = val
case float64:
c.DurationMinutes = int(val)
}
case "requiredforroles", "required_for_roles":
if val, ok := v.([]string); ok {
c.RequiredForRoles = val
}
case "status":
if val, ok := v.(string); ok {
c.Status = val
}
}
}
c.UpdatedAt = time.Now()
return c, nil
}
// DeleteCourse removes a course and all related lessons, quiz questions,
// enrollments, certificates, and lesson progress.
func (s *AcademyMemStore) DeleteCourse(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.courses[id]; !ok {
return fmt.Errorf("course not found: %s", id)
}
// Collect lesson IDs for this course
lessonIDs := make(map[string]bool)
for lid, l := range s.lessons {
if l.CourseID == id {
lessonIDs[lid] = true
}
}
// Delete quiz questions belonging to those lessons
for qid, q := range s.quizQuestions {
if lessonIDs[q.LessonID] {
delete(s.quizQuestions, qid)
}
}
// Delete lessons
for lid := range lessonIDs {
delete(s.lessons, lid)
}
// Collect enrollment IDs for this course
enrollmentIDs := make(map[string]bool)
for eid, e := range s.enrollments {
if e.CourseID == id {
enrollmentIDs[eid] = true
}
}
// Delete lesson progress belonging to those enrollments
for pid, p := range s.lessonProgress {
if enrollmentIDs[p.EnrollmentID] {
delete(s.lessonProgress, pid)
}
}
// Delete certificates belonging to those enrollments
for cid, cert := range s.certificates {
if cert.CourseID == id {
delete(s.certificates, cid)
}
}
// Delete enrollments
for eid := range enrollmentIDs {
delete(s.enrollments, eid)
}
// Delete the course itself
delete(s.courses, id)
return nil
}
// ---------------------------------------------------------------------------
// Lesson CRUD
// ---------------------------------------------------------------------------
// ListLessons returns all lessons for a course, sorted by SortOrder ASC.
func (s *AcademyMemStore) ListLessons(courseID string) []*AcademyLessonRow {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*AcademyLessonRow
for _, l := range s.lessons {
if l.CourseID == courseID {
result = append(result, l)
}
}
sort.Slice(result, func(i, j int) bool {
return result[i].SortOrder < result[j].SortOrder
})
return result
}
// GetLesson retrieves a single lesson by ID.
func (s *AcademyMemStore) GetLesson(id string) (*AcademyLessonRow, error) {
s.mu.RLock()
defer s.mu.RUnlock()
l, ok := s.lessons[id]
if !ok {
return nil, fmt.Errorf("lesson not found: %s", id)
}
return l, nil
}
// CreateLesson inserts a new lesson with auto-generated ID and timestamps.
func (s *AcademyMemStore) CreateLesson(row *AcademyLessonRow) *AcademyLessonRow {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
row.ID = generateID()
row.CreatedAt = now
row.UpdatedAt = now
s.lessons[row.ID] = row
return row
}
// UpdateLesson partially updates a lesson. Supported keys: Title, Type,
// ContentMarkdown, VideoURL, AudioURL, SortOrder, DurationMinutes.
func (s *AcademyMemStore) UpdateLesson(id string, updates map[string]interface{}) (*AcademyLessonRow, error) {
s.mu.Lock()
defer s.mu.Unlock()
l, ok := s.lessons[id]
if !ok {
return nil, fmt.Errorf("lesson not found: %s", id)
}
for k, v := range updates {
switch strings.ToLower(k) {
case "title":
if val, ok := v.(string); ok {
l.Title = val
}
case "type":
if val, ok := v.(string); ok {
l.Type = val
}
case "contentmarkdown", "content_markdown":
if val, ok := v.(string); ok {
l.ContentMarkdown = val
}
case "videourl", "video_url":
if val, ok := v.(string); ok {
l.VideoURL = val
}
case "audiourl", "audio_url":
if val, ok := v.(string); ok {
l.AudioURL = val
}
case "sortorder", "sort_order":
switch val := v.(type) {
case int:
l.SortOrder = val
case float64:
l.SortOrder = int(val)
}
case "durationminutes", "duration_minutes":
switch val := v.(type) {
case int:
l.DurationMinutes = val
case float64:
l.DurationMinutes = int(val)
}
}
}
l.UpdatedAt = time.Now()
return l, nil
}
// DeleteLesson removes a lesson and its quiz questions.
func (s *AcademyMemStore) DeleteLesson(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.lessons[id]; !ok {
return fmt.Errorf("lesson not found: %s", id)
}
// Delete quiz questions belonging to this lesson
for qid, q := range s.quizQuestions {
if q.LessonID == id {
delete(s.quizQuestions, qid)
}
}
delete(s.lessons, id)
return nil
}
// ---------------------------------------------------------------------------
// Quiz Questions
// ---------------------------------------------------------------------------
// ListQuizQuestions returns all quiz questions for a lesson, sorted by SortOrder ASC.
func (s *AcademyMemStore) ListQuizQuestions(lessonID string) []*AcademyQuizQuestionRow {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*AcademyQuizQuestionRow
for _, q := range s.quizQuestions {
if q.LessonID == lessonID {
result = append(result, q)
}
}
sort.Slice(result, func(i, j int) bool {
return result[i].SortOrder < result[j].SortOrder
})
return result
}
// CreateQuizQuestion inserts a new quiz question with auto-generated ID and timestamp.
func (s *AcademyMemStore) CreateQuizQuestion(row *AcademyQuizQuestionRow) *AcademyQuizQuestionRow {
s.mu.Lock()
defer s.mu.Unlock()
row.ID = generateID()
row.CreatedAt = time.Now()
s.quizQuestions[row.ID] = row
return row
}
// ---------------------------------------------------------------------------
// Enrollments
// ---------------------------------------------------------------------------
// ListEnrollments returns enrollments filtered by tenantID and optionally by courseID.
// If courseID is empty, all enrollments for the tenant are returned.
func (s *AcademyMemStore) ListEnrollments(tenantID string, courseID string) []*AcademyEnrollmentRow {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*AcademyEnrollmentRow
for _, e := range s.enrollments {
if e.TenantID != tenantID {
continue
}
if courseID != "" && e.CourseID != courseID {
continue
}
result = append(result, e)
}
sort.Slice(result, func(i, j int) bool {
return result[i].UpdatedAt.After(result[j].UpdatedAt)
})
return result
}
// GetEnrollment retrieves a single enrollment by ID.
func (s *AcademyMemStore) GetEnrollment(id string) (*AcademyEnrollmentRow, error) {
s.mu.RLock()
defer s.mu.RUnlock()
e, ok := s.enrollments[id]
if !ok {
return nil, fmt.Errorf("enrollment not found: %s", id)
}
return e, nil
}
// CreateEnrollment inserts a new enrollment with auto-generated ID and timestamps.
func (s *AcademyMemStore) CreateEnrollment(row *AcademyEnrollmentRow) *AcademyEnrollmentRow {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
row.ID = generateID()
row.CreatedAt = now
row.UpdatedAt = now
if row.StartedAt.IsZero() {
row.StartedAt = now
}
s.enrollments[row.ID] = row
return row
}
// UpdateEnrollment partially updates an enrollment. Supported keys: Status,
// Progress, CompletedAt, CertificateID, Deadline.
func (s *AcademyMemStore) UpdateEnrollment(id string, updates map[string]interface{}) (*AcademyEnrollmentRow, error) {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.enrollments[id]
if !ok {
return nil, fmt.Errorf("enrollment not found: %s", id)
}
for k, v := range updates {
switch strings.ToLower(k) {
case "status":
if val, ok := v.(string); ok {
e.Status = val
}
case "progress":
switch val := v.(type) {
case int:
e.Progress = val
case float64:
e.Progress = int(val)
}
case "completedat", "completed_at":
if val, ok := v.(*time.Time); ok {
e.CompletedAt = val
} else if val, ok := v.(time.Time); ok {
e.CompletedAt = &val
}
case "certificateid", "certificate_id":
if val, ok := v.(string); ok {
e.CertificateID = val
}
case "deadline":
if val, ok := v.(time.Time); ok {
e.Deadline = val
}
}
}
e.UpdatedAt = time.Now()
return e, nil
}
// ---------------------------------------------------------------------------
// Certificates
// ---------------------------------------------------------------------------
// GetCertificate retrieves a certificate by ID.
func (s *AcademyMemStore) GetCertificate(id string) (*AcademyCertificateRow, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cert, ok := s.certificates[id]
if !ok {
return nil, fmt.Errorf("certificate not found: %s", id)
}
return cert, nil
}
// GetCertificateByEnrollment retrieves a certificate by enrollment ID.
func (s *AcademyMemStore) GetCertificateByEnrollment(enrollmentID string) (*AcademyCertificateRow, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, cert := range s.certificates {
if cert.EnrollmentID == enrollmentID {
return cert, nil
}
}
return nil, fmt.Errorf("certificate not found for enrollment: %s", enrollmentID)
}
// CreateCertificate inserts a new certificate with auto-generated ID.
func (s *AcademyMemStore) CreateCertificate(row *AcademyCertificateRow) *AcademyCertificateRow {
s.mu.Lock()
defer s.mu.Unlock()
row.ID = generateID()
if row.IssuedAt.IsZero() {
row.IssuedAt = time.Now()
}
s.certificates[row.ID] = row
return row
}
// ---------------------------------------------------------------------------
// Lesson Progress
// ---------------------------------------------------------------------------
// ListLessonProgress returns all progress entries for an enrollment.
func (s *AcademyMemStore) ListLessonProgress(enrollmentID string) []*AcademyLessonProgressRow {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*AcademyLessonProgressRow
for _, p := range s.lessonProgress {
if p.EnrollmentID == enrollmentID {
result = append(result, p)
}
}
return result
}
// UpsertLessonProgress inserts or updates a lesson progress entry.
// Matching is done by EnrollmentID + LessonID composite key.
func (s *AcademyMemStore) UpsertLessonProgress(row *AcademyLessonProgressRow) *AcademyLessonProgressRow {
s.mu.Lock()
defer s.mu.Unlock()
// Look for existing entry with same enrollment_id + lesson_id
for _, p := range s.lessonProgress {
if p.EnrollmentID == row.EnrollmentID && p.LessonID == row.LessonID {
p.Completed = row.Completed
p.QuizScore = row.QuizScore
p.CompletedAt = row.CompletedAt
return p
}
}
// Insert new entry
row.ID = generateID()
s.lessonProgress[row.ID] = row
return row
}
// ---------------------------------------------------------------------------
// Statistics
// ---------------------------------------------------------------------------
// GetStatistics computes aggregate statistics for a tenant.
func (s *AcademyMemStore) GetStatistics(tenantID string) *AcademyStatisticsRow {
s.mu.RLock()
defer s.mu.RUnlock()
stats := &AcademyStatisticsRow{
ByCategory: make(map[string]int),
ByStatus: make(map[string]int),
}
// Count courses by category
for _, c := range s.courses {
if c.TenantID != tenantID {
continue
}
stats.TotalCourses++
if c.Category != "" {
stats.ByCategory[c.Category]++
}
}
// Count enrollments and compute completion rate
var completedCount int
now := time.Now()
for _, e := range s.enrollments {
if e.TenantID != tenantID {
continue
}
stats.TotalEnrollments++
stats.ByStatus[e.Status]++
if e.Status == "completed" {
completedCount++
}
// Overdue: not completed and past deadline
if e.Status != "completed" && !e.Deadline.IsZero() && now.After(e.Deadline) {
stats.OverdueCount++
}
}
if stats.TotalEnrollments > 0 {
stats.CompletionRate = float64(completedCount) / float64(stats.TotalEnrollments) * 100.0
}
return stats
}

View File

@@ -0,0 +1,371 @@
package gci
import (
"fmt"
"math"
"time"
)
// Engine calculates the GCI score
type Engine struct{}
// NewEngine creates a new GCI calculation engine
func NewEngine() *Engine {
return &Engine{}
}
// Calculate computes the full GCI result for a tenant
func (e *Engine) Calculate(tenantID string, profileID string) *GCIResult {
now := time.Now()
profile := GetProfile(profileID)
auditTrail := []AuditEntry{}
// Step 1: Get module data (mock for now)
modules := MockModuleData(tenantID)
certDates := MockCertificateData()
// Step 2: Calculate Level 1 - Module Scores with validity
for i := range modules {
m := &modules[i]
if m.Assigned > 0 {
m.RawScore = float64(m.Completed) / float64(m.Assigned) * 100.0
}
// Apply validity factor
if validUntil, ok := certDates[m.ModuleID]; ok {
m.ValidityFactor = CalculateValidityFactor(validUntil, now)
} else {
m.ValidityFactor = 1.0 // No certificate tracking = assume valid
}
m.FinalScore = m.RawScore * m.ValidityFactor
if m.ValidityFactor < 1.0 {
auditTrail = append(auditTrail, AuditEntry{
Timestamp: now,
Factor: "validity_decay",
Description: fmt.Sprintf("Modul '%s': Gueltigkeitsfaktor %.2f (Zertifikat laeuft ab/abgelaufen)", m.ModuleName, m.ValidityFactor),
Value: m.ValidityFactor,
Impact: "negative",
})
}
}
// Step 3: Calculate Level 2 - Risk-Weighted Scores per area
areaModules := map[string][]ModuleScore{
"dsgvo": {},
"nis2": {},
"iso27001": {},
"ai_act": {},
}
for _, m := range modules {
if _, ok := areaModules[m.Category]; ok {
areaModules[m.Category] = append(areaModules[m.Category], m)
}
}
level2Areas := []RiskWeightedScore{}
areaNames := map[string]string{
"dsgvo": "DSGVO",
"nis2": "NIS2",
"iso27001": "ISO 27001",
"ai_act": "EU AI Act",
}
for areaID, mods := range areaModules {
rws := RiskWeightedScore{
AreaID: areaID,
AreaName: areaNames[areaID],
Modules: mods,
}
for _, m := range mods {
rws.WeightedSum += m.FinalScore * m.RiskWeight
rws.TotalWeight += m.RiskWeight
}
if rws.TotalWeight > 0 {
rws.AreaScore = rws.WeightedSum / rws.TotalWeight
}
level2Areas = append(level2Areas, rws)
}
// Step 4: Calculate Level 3 - Regulation Area Scores
areaScores := []RegulationAreaScore{}
for _, rws := range level2Areas {
weight := profile.Weights[rws.AreaID]
completedCount := 0
for _, m := range rws.Modules {
if m.Completed >= m.Assigned && m.Assigned > 0 {
completedCount++
}
}
ras := RegulationAreaScore{
RegulationID: rws.AreaID,
RegulationName: rws.AreaName,
Score: math.Round(rws.AreaScore*100) / 100,
Weight: weight,
WeightedScore: rws.AreaScore * weight,
ModuleCount: len(rws.Modules),
CompletedCount: completedCount,
}
areaScores = append(areaScores, ras)
auditTrail = append(auditTrail, AuditEntry{
Timestamp: now,
Factor: "area_score",
Description: fmt.Sprintf("Bereich '%s': Score %.1f, Gewicht %.0f%%", rws.AreaName, rws.AreaScore, weight*100),
Value: rws.AreaScore,
Impact: "neutral",
})
}
// Step 5: Calculate raw GCI
rawGCI := 0.0
totalWeight := 0.0
for _, ras := range areaScores {
rawGCI += ras.WeightedScore
totalWeight += ras.Weight
}
if totalWeight > 0 {
rawGCI = rawGCI / totalWeight
}
// Step 6: Apply Criticality Multiplier
criticalityMult := calculateCriticalityMultiplier(modules)
auditTrail = append(auditTrail, AuditEntry{
Timestamp: now,
Factor: "criticality_multiplier",
Description: fmt.Sprintf("Kritikalitaetsmultiplikator: %.3f", criticalityMult),
Value: criticalityMult,
Impact: func() string {
if criticalityMult < 1.0 {
return "negative"
}
return "neutral"
}(),
})
// Step 7: Apply Incident Adjustment
openInc, critInc := MockIncidentData()
incidentAdj := calculateIncidentAdjustment(openInc, critInc)
auditTrail = append(auditTrail, AuditEntry{
Timestamp: now,
Factor: "incident_adjustment",
Description: fmt.Sprintf("Vorfallsanpassung: %.3f (%d offen, %d kritisch)", incidentAdj, openInc, critInc),
Value: incidentAdj,
Impact: "negative",
})
// Step 8: Final GCI
finalGCI := rawGCI * criticalityMult * incidentAdj
finalGCI = math.Max(0, math.Min(100, math.Round(finalGCI*10)/10))
// Step 9: Determine Maturity Level
maturity := determineMaturityLevel(finalGCI)
auditTrail = append(auditTrail, AuditEntry{
Timestamp: now,
Factor: "final_gci",
Description: fmt.Sprintf("GCI-Endergebnis: %.1f → Reifegrad: %s", finalGCI, MaturityLabels[maturity]),
Value: finalGCI,
Impact: "neutral",
})
return &GCIResult{
TenantID: tenantID,
GCIScore: finalGCI,
MaturityLevel: maturity,
MaturityLabel: MaturityLabels[maturity],
CalculatedAt: now,
Profile: profileID,
AreaScores: areaScores,
CriticalityMult: criticalityMult,
IncidentAdj: incidentAdj,
AuditTrail: auditTrail,
}
}
// CalculateBreakdown returns the full 4-level breakdown
func (e *Engine) CalculateBreakdown(tenantID string, profileID string) *GCIBreakdown {
result := e.Calculate(tenantID, profileID)
modules := MockModuleData(tenantID)
certDates := MockCertificateData()
now := time.Now()
// Recalculate module scores for the breakdown
for i := range modules {
m := &modules[i]
if m.Assigned > 0 {
m.RawScore = float64(m.Completed) / float64(m.Assigned) * 100.0
}
if validUntil, ok := certDates[m.ModuleID]; ok {
m.ValidityFactor = CalculateValidityFactor(validUntil, now)
} else {
m.ValidityFactor = 1.0
}
m.FinalScore = m.RawScore * m.ValidityFactor
}
// Build Level 2 areas
areaModules := map[string][]ModuleScore{}
for _, m := range modules {
areaModules[m.Category] = append(areaModules[m.Category], m)
}
areaNames := map[string]string{"dsgvo": "DSGVO", "nis2": "NIS2", "iso27001": "ISO 27001", "ai_act": "EU AI Act"}
level2 := []RiskWeightedScore{}
for areaID, mods := range areaModules {
rws := RiskWeightedScore{AreaID: areaID, AreaName: areaNames[areaID], Modules: mods}
for _, m := range mods {
rws.WeightedSum += m.FinalScore * m.RiskWeight
rws.TotalWeight += m.RiskWeight
}
if rws.TotalWeight > 0 {
rws.AreaScore = rws.WeightedSum / rws.TotalWeight
}
level2 = append(level2, rws)
}
return &GCIBreakdown{
GCIResult: *result,
Level1Modules: modules,
Level2Areas: level2,
}
}
// GetHistory returns historical GCI snapshots
func (e *Engine) GetHistory(tenantID string) []GCISnapshot {
// Add current score to history
result := e.Calculate(tenantID, "default")
history := MockGCIHistory(tenantID)
current := GCISnapshot{
TenantID: tenantID,
Score: result.GCIScore,
MaturityLevel: result.MaturityLevel,
AreaScores: make(map[string]float64),
CalculatedAt: result.CalculatedAt,
}
for _, as := range result.AreaScores {
current.AreaScores[as.RegulationID] = as.Score
}
history = append(history, current)
return history
}
// GetMatrix returns the compliance matrix (roles x regulations)
func (e *Engine) GetMatrix(tenantID string) []ComplianceMatrixEntry {
modules := MockModuleData(tenantID)
roles := []struct {
ID string
Name string
}{
{"management", "Geschaeftsfuehrung"},
{"it_security", "IT-Sicherheit / CISO"},
{"data_protection", "Datenschutz / DSB"},
{"hr", "Personalwesen"},
{"general", "Allgemeine Mitarbeiter"},
}
// Define which modules are relevant per role
roleModules := map[string][]string{
"management": {"dsgvo-grundlagen", "nis2-management", "ai-governance", "iso-isms"},
"it_security": {"nis2-risikomanagement", "nis2-incident-response", "iso-zugangssteuerung", "iso-kryptografie", "ai-hochrisiko"},
"data_protection": {"dsgvo-grundlagen", "dsgvo-betroffenenrechte", "dsgvo-tom", "dsgvo-dsfa", "dsgvo-auftragsverarbeitung"},
"hr": {"dsgvo-grundlagen", "dsgvo-betroffenenrechte", "nis2-management"},
"general": {"dsgvo-grundlagen", "nis2-risikomanagement", "ai-risikokategorien", "ai-transparenz"},
}
moduleMap := map[string]ModuleScore{}
for _, m := range modules {
moduleMap[m.ModuleID] = m
}
entries := []ComplianceMatrixEntry{}
for _, role := range roles {
entry := ComplianceMatrixEntry{
Role: role.ID,
RoleName: role.Name,
Regulations: map[string]float64{},
}
regScores := map[string][]float64{}
requiredModuleIDs := roleModules[role.ID]
entry.RequiredModules = len(requiredModuleIDs)
for _, modID := range requiredModuleIDs {
if m, ok := moduleMap[modID]; ok {
score := 0.0
if m.Assigned > 0 {
score = float64(m.Completed) / float64(m.Assigned) * 100
}
regScores[m.Category] = append(regScores[m.Category], score)
if m.Completed >= m.Assigned && m.Assigned > 0 {
entry.CompletedModules++
}
}
}
totalScore := 0.0
count := 0
for reg, scores := range regScores {
sum := 0.0
for _, s := range scores {
sum += s
}
avg := sum / float64(len(scores))
entry.Regulations[reg] = math.Round(avg*10) / 10
totalScore += avg
count++
}
if count > 0 {
entry.OverallScore = math.Round(totalScore/float64(count)*10) / 10
}
entries = append(entries, entry)
}
return entries
}
// Helper functions
func calculateCriticalityMultiplier(modules []ModuleScore) float64 {
criticalModules := 0
criticalLow := 0
for _, m := range modules {
if m.RiskWeight >= 2.5 {
criticalModules++
if m.FinalScore < 50 {
criticalLow++
}
}
}
if criticalModules == 0 {
return 1.0
}
// Reduce score if critical modules have low completion
ratio := float64(criticalLow) / float64(criticalModules)
return 1.0 - (ratio * 0.15) // max 15% reduction
}
func calculateIncidentAdjustment(openIncidents, criticalIncidents int) float64 {
adj := 1.0
// Each open incident reduces by 1%
adj -= float64(openIncidents) * 0.01
// Each critical incident reduces by additional 3%
adj -= float64(criticalIncidents) * 0.03
return math.Max(0.8, adj) // minimum 80% (max 20% reduction)
}
func determineMaturityLevel(score float64) string {
switch {
case score >= 90:
return MaturityOptimized
case score >= 75:
return MaturityManaged
case score >= 60:
return MaturityDefined
case score >= 40:
return MaturityReactive
default:
return MaturityHighRisk
}
}

View File

@@ -0,0 +1,188 @@
package gci
import "math"
// ISOGapAnalysis represents the complete ISO 27001 gap analysis
type ISOGapAnalysis struct {
TenantID string `json:"tenant_id"`
TotalControls int `json:"total_controls"`
CoveredFull int `json:"covered_full"`
CoveredPartial int `json:"covered_partial"`
NotCovered int `json:"not_covered"`
CoveragePercent float64 `json:"coverage_percent"`
CategorySummaries []ISOCategorySummary `json:"category_summaries"`
ControlDetails []ISOControlDetail `json:"control_details"`
Gaps []ISOGap `json:"gaps"`
}
// ISOControlDetail shows coverage status for a single control
type ISOControlDetail struct {
Control ISOControl `json:"control"`
CoverageLevel string `json:"coverage_level"` // full, partial, none
CoveredBy []string `json:"covered_by"` // module IDs
Score float64 `json:"score"` // 0-100
}
// ISOGap represents an identified gap in ISO coverage
type ISOGap struct {
ControlID string `json:"control_id"`
ControlName string `json:"control_name"`
Category string `json:"category"`
Priority string `json:"priority"` // high, medium, low
Recommendation string `json:"recommendation"`
}
// CalculateISOGapAnalysis performs the ISO 27001 gap analysis
func CalculateISOGapAnalysis(tenantID string) *ISOGapAnalysis {
modules := MockModuleData(tenantID)
moduleMap := map[string]ModuleScore{}
for _, m := range modules {
moduleMap[m.ModuleID] = m
}
// Build reverse mapping: control -> modules covering it
controlCoverage := map[string][]string{}
controlCoverageLevel := map[string]string{}
for _, mapping := range DefaultISOModuleMappings {
for _, controlID := range mapping.ISOControls {
controlCoverage[controlID] = append(controlCoverage[controlID], mapping.ModuleID)
// Use the highest coverage level
existingLevel := controlCoverageLevel[controlID]
if mapping.CoverageLevel == "full" || existingLevel == "" {
controlCoverageLevel[controlID] = mapping.CoverageLevel
}
}
}
// Analyze each control
details := []ISOControlDetail{}
gaps := []ISOGap{}
coveredFull := 0
coveredPartial := 0
notCovered := 0
categoryCounts := map[string]*ISOCategorySummary{
"A.5": {CategoryID: "A.5", CategoryName: "Organisatorische Massnahmen"},
"A.6": {CategoryID: "A.6", CategoryName: "Personelle Massnahmen"},
"A.7": {CategoryID: "A.7", CategoryName: "Physische Massnahmen"},
"A.8": {CategoryID: "A.8", CategoryName: "Technologische Massnahmen"},
}
for _, control := range ISOControls {
coveredBy := controlCoverage[control.ID]
level := controlCoverageLevel[control.ID]
if len(coveredBy) == 0 {
level = "none"
}
// Calculate score based on module completion
score := 0.0
if len(coveredBy) > 0 {
scoreSum := 0.0
count := 0
for _, modID := range coveredBy {
if m, ok := moduleMap[modID]; ok && m.Assigned > 0 {
scoreSum += float64(m.Completed) / float64(m.Assigned) * 100
count++
}
}
if count > 0 {
score = scoreSum / float64(count)
}
// Adjust for coverage level
if level == "partial" {
score *= 0.7 // partial coverage reduces effective score
}
}
detail := ISOControlDetail{
Control: control,
CoverageLevel: level,
CoveredBy: coveredBy,
Score: math.Round(score*10) / 10,
}
details = append(details, detail)
// Count by category
cat := categoryCounts[control.CategoryID]
if cat != nil {
cat.TotalControls++
switch level {
case "full":
coveredFull++
cat.CoveredFull++
case "partial":
coveredPartial++
cat.CoveredPartial++
default:
notCovered++
cat.NotCovered++
// Generate gap recommendation
gap := ISOGap{
ControlID: control.ID,
ControlName: control.Name,
Category: control.Category,
Priority: determineGapPriority(control),
Recommendation: generateGapRecommendation(control),
}
gaps = append(gaps, gap)
}
}
}
totalControls := len(ISOControls)
coveragePercent := 0.0
if totalControls > 0 {
coveragePercent = math.Round(float64(coveredFull+coveredPartial)/float64(totalControls)*100*10) / 10
}
summaries := []ISOCategorySummary{}
for _, catID := range []string{"A.5", "A.6", "A.7", "A.8"} {
if cat, ok := categoryCounts[catID]; ok {
summaries = append(summaries, *cat)
}
}
return &ISOGapAnalysis{
TenantID: tenantID,
TotalControls: totalControls,
CoveredFull: coveredFull,
CoveredPartial: coveredPartial,
NotCovered: notCovered,
CoveragePercent: coveragePercent,
CategorySummaries: summaries,
ControlDetails: details,
Gaps: gaps,
}
}
func determineGapPriority(control ISOControl) string {
// High priority for access, incident, and data protection controls
highPriority := map[string]bool{
"A.5.15": true, "A.5.17": true, "A.5.24": true, "A.5.26": true,
"A.5.34": true, "A.8.2": true, "A.8.5": true, "A.8.7": true,
"A.8.10": true, "A.8.20": true,
}
if highPriority[control.ID] {
return "high"
}
// Medium for organizational and people controls
if control.CategoryID == "A.5" || control.CategoryID == "A.6" {
return "medium"
}
return "low"
}
func generateGapRecommendation(control ISOControl) string {
recommendations := map[string]string{
"organizational": "Erstellen Sie eine Richtlinie und weisen Sie Verantwortlichkeiten zu fuer: " + control.Name,
"people": "Implementieren Sie Schulungen und Prozesse fuer: " + control.Name,
"physical": "Definieren Sie physische Sicherheitsmassnahmen fuer: " + control.Name,
"technological": "Implementieren Sie technische Kontrollen fuer: " + control.Name,
}
if rec, ok := recommendations[control.Category]; ok {
return rec
}
return "Massnahmen implementieren fuer: " + control.Name
}

View File

@@ -0,0 +1,207 @@
package gci
// ISOControl represents an ISO 27001:2022 Annex A control
type ISOControl struct {
ID string `json:"id"` // e.g. "A.5.1"
Name string `json:"name"`
Category string `json:"category"` // organizational, people, physical, technological
CategoryID string `json:"category_id"` // A.5, A.6, A.7, A.8
Description string `json:"description"`
}
// ISOModuleMapping maps a course/module to ISO controls
type ISOModuleMapping struct {
ModuleID string `json:"module_id"`
ModuleName string `json:"module_name"`
ISOControls []string `json:"iso_controls"` // control IDs
CoverageLevel string `json:"coverage_level"` // full, partial, none
}
// ISO 27001:2022 Annex A controls (representative selection)
var ISOControls = []ISOControl{
// A.5 Organizational Controls (37 controls, showing key ones)
{ID: "A.5.1", Name: "Informationssicherheitsrichtlinien", Category: "organizational", CategoryID: "A.5", Description: "Informationssicherheitsleitlinie und themenspezifische Richtlinien"},
{ID: "A.5.2", Name: "Rollen und Verantwortlichkeiten", Category: "organizational", CategoryID: "A.5", Description: "Definition und Zuweisung von Informationssicherheitsrollen"},
{ID: "A.5.3", Name: "Aufgabentrennung", Category: "organizational", CategoryID: "A.5", Description: "Trennung von konfligierenden Aufgaben und Verantwortlichkeiten"},
{ID: "A.5.4", Name: "Managementverantwortung", Category: "organizational", CategoryID: "A.5", Description: "Fuehrungskraefte muessen Sicherheitsrichtlinien einhalten und durchsetzen"},
{ID: "A.5.5", Name: "Kontakt mit Behoerden", Category: "organizational", CategoryID: "A.5", Description: "Pflege von Kontakten zu relevanten Aufsichtsbehoerden"},
{ID: "A.5.6", Name: "Kontakt mit Interessengruppen", Category: "organizational", CategoryID: "A.5", Description: "Kontakt zu Fachgruppen und Sicherheitsforen"},
{ID: "A.5.7", Name: "Bedrohungsintelligenz", Category: "organizational", CategoryID: "A.5", Description: "Sammlung und Analyse von Bedrohungsinformationen"},
{ID: "A.5.8", Name: "Informationssicherheit im Projektmanagement", Category: "organizational", CategoryID: "A.5", Description: "Integration von Sicherheit in Projektmanagement"},
{ID: "A.5.9", Name: "Inventar der Informationswerte", Category: "organizational", CategoryID: "A.5", Description: "Inventarisierung und Verwaltung von Informationswerten"},
{ID: "A.5.10", Name: "Zuleassige Nutzung", Category: "organizational", CategoryID: "A.5", Description: "Regeln fuer die zuleassige Nutzung von Informationswerten"},
{ID: "A.5.11", Name: "Rueckgabe von Werten", Category: "organizational", CategoryID: "A.5", Description: "Rueckgabe von Werten bei Beendigung"},
{ID: "A.5.12", Name: "Klassifizierung von Informationen", Category: "organizational", CategoryID: "A.5", Description: "Klassifizierungsschema fuer Informationen"},
{ID: "A.5.13", Name: "Kennzeichnung von Informationen", Category: "organizational", CategoryID: "A.5", Description: "Kennzeichnung gemaess Klassifizierung"},
{ID: "A.5.14", Name: "Informationsuebertragung", Category: "organizational", CategoryID: "A.5", Description: "Regeln fuer sichere Informationsuebertragung"},
{ID: "A.5.15", Name: "Zugangssteuerung", Category: "organizational", CategoryID: "A.5", Description: "Zugangssteuerungsrichtlinie"},
{ID: "A.5.16", Name: "Identitaetsmanagement", Category: "organizational", CategoryID: "A.5", Description: "Verwaltung des Lebenszyklus von Identitaeten"},
{ID: "A.5.17", Name: "Authentifizierungsinformationen", Category: "organizational", CategoryID: "A.5", Description: "Verwaltung von Authentifizierungsinformationen"},
{ID: "A.5.18", Name: "Zugriffsrechte", Category: "organizational", CategoryID: "A.5", Description: "Vergabe, Pruefung und Entzug von Zugriffsrechten"},
{ID: "A.5.19", Name: "Informationssicherheit in Lieferantenbeziehungen", Category: "organizational", CategoryID: "A.5", Description: "Sicherheitsanforderungen an Lieferanten"},
{ID: "A.5.20", Name: "Informationssicherheit in Lieferantenvereinbarungen", Category: "organizational", CategoryID: "A.5", Description: "Sicherheitsklauseln in Vertraegen"},
{ID: "A.5.21", Name: "IKT-Lieferkette", Category: "organizational", CategoryID: "A.5", Description: "Management der IKT-Lieferkette"},
{ID: "A.5.22", Name: "Ueberwachung von Lieferantenservices", Category: "organizational", CategoryID: "A.5", Description: "Ueberwachung und Pruefung von Lieferantenservices"},
{ID: "A.5.23", Name: "Cloud-Sicherheit", Category: "organizational", CategoryID: "A.5", Description: "Informationssicherheit fuer Cloud-Dienste"},
{ID: "A.5.24", Name: "Vorfallsmanagement - Planung", Category: "organizational", CategoryID: "A.5", Description: "Planung und Vorbereitung des Vorfallsmanagements"},
{ID: "A.5.25", Name: "Vorfallsbeurteilung", Category: "organizational", CategoryID: "A.5", Description: "Beurteilung und Entscheidung ueber Sicherheitsereignisse"},
{ID: "A.5.26", Name: "Vorfallsreaktion", Category: "organizational", CategoryID: "A.5", Description: "Reaktion auf Sicherheitsvorfaelle"},
{ID: "A.5.27", Name: "Aus Vorfaellen lernen", Category: "organizational", CategoryID: "A.5", Description: "Lessons Learned aus Sicherheitsvorfaellen"},
{ID: "A.5.28", Name: "Beweissicherung", Category: "organizational", CategoryID: "A.5", Description: "Identifikation und Sicherung von Beweisen"},
{ID: "A.5.29", Name: "Informationssicherheit bei Stoerungen", Category: "organizational", CategoryID: "A.5", Description: "Sicherheit waehrend Stoerungen und Krisen"},
{ID: "A.5.30", Name: "IKT-Bereitschaft fuer Business Continuity", Category: "organizational", CategoryID: "A.5", Description: "IKT-Bereitschaft zur Unterstuetzung der Geschaeftskontinuitaet"},
{ID: "A.5.31", Name: "Rechtliche Anforderungen", Category: "organizational", CategoryID: "A.5", Description: "Einhaltung rechtlicher und vertraglicher Anforderungen"},
{ID: "A.5.32", Name: "Geistige Eigentumsrechte", Category: "organizational", CategoryID: "A.5", Description: "Schutz geistigen Eigentums"},
{ID: "A.5.33", Name: "Schutz von Aufzeichnungen", Category: "organizational", CategoryID: "A.5", Description: "Schutz von Aufzeichnungen vor Verlust und Manipulation"},
{ID: "A.5.34", Name: "Datenschutz und PII", Category: "organizational", CategoryID: "A.5", Description: "Datenschutz und Schutz personenbezogener Daten"},
{ID: "A.5.35", Name: "Unabhaengige Ueberpruefung", Category: "organizational", CategoryID: "A.5", Description: "Unabhaengige Ueberpruefung der Informationssicherheit"},
{ID: "A.5.36", Name: "Richtlinienkonformitaet", Category: "organizational", CategoryID: "A.5", Description: "Einhaltung von Richtlinien und Standards"},
{ID: "A.5.37", Name: "Dokumentierte Betriebsverfahren", Category: "organizational", CategoryID: "A.5", Description: "Dokumentation von Betriebsverfahren"},
// A.6 People Controls (8 controls)
{ID: "A.6.1", Name: "Ueberpruefen", Category: "people", CategoryID: "A.6", Description: "Hintergrundpruefungen vor der Einstellung"},
{ID: "A.6.2", Name: "Beschaeftigungsbedingungen", Category: "people", CategoryID: "A.6", Description: "Sicherheitsanforderungen in Arbeitsvertraegen"},
{ID: "A.6.3", Name: "Sensibilisierung und Schulung", Category: "people", CategoryID: "A.6", Description: "Awareness-Programme und Schulungen"},
{ID: "A.6.4", Name: "Disziplinarverfahren", Category: "people", CategoryID: "A.6", Description: "Formales Disziplinarverfahren"},
{ID: "A.6.5", Name: "Verantwortlichkeiten nach Beendigung", Category: "people", CategoryID: "A.6", Description: "Sicherheitspflichten nach Beendigung des Beschaeftigungsverhaeltnisses"},
{ID: "A.6.6", Name: "Vertraulichkeitsvereinbarungen", Category: "people", CategoryID: "A.6", Description: "Vertraulichkeits- und Geheimhaltungsvereinbarungen"},
{ID: "A.6.7", Name: "Remote-Arbeit", Category: "people", CategoryID: "A.6", Description: "Sicherheitsmassnahmen fuer Remote-Arbeit"},
{ID: "A.6.8", Name: "Meldung von Sicherheitsereignissen", Category: "people", CategoryID: "A.6", Description: "Mechanismen zur Meldung von Sicherheitsereignissen"},
// A.7 Physical Controls (14 controls, showing key ones)
{ID: "A.7.1", Name: "Physische Sicherheitsperimeter", Category: "physical", CategoryID: "A.7", Description: "Definition physischer Sicherheitszonen"},
{ID: "A.7.2", Name: "Physischer Zutritt", Category: "physical", CategoryID: "A.7", Description: "Zutrittskontrolle zu Sicherheitszonen"},
{ID: "A.7.3", Name: "Sicherung von Bueros und Raeumen", Category: "physical", CategoryID: "A.7", Description: "Physische Sicherheit fuer Bueros und Raeume"},
{ID: "A.7.4", Name: "Physische Sicherheitsueberwachung", Category: "physical", CategoryID: "A.7", Description: "Ueberwachung physischer Sicherheit"},
{ID: "A.7.5", Name: "Schutz vor Umweltgefahren", Category: "physical", CategoryID: "A.7", Description: "Schutz gegen natuerliche und menschgemachte Gefahren"},
{ID: "A.7.6", Name: "Arbeit in Sicherheitszonen", Category: "physical", CategoryID: "A.7", Description: "Regeln fuer das Arbeiten in Sicherheitszonen"},
{ID: "A.7.7", Name: "Aufgeraemter Schreibtisch", Category: "physical", CategoryID: "A.7", Description: "Clean-Desk und Clear-Screen Richtlinie"},
{ID: "A.7.8", Name: "Geraeteplatzierung", Category: "physical", CategoryID: "A.7", Description: "Platzierung und Schutz von Geraeten"},
{ID: "A.7.9", Name: "Sicherheit von Geraeten ausserhalb", Category: "physical", CategoryID: "A.7", Description: "Sicherheit von Geraeten ausserhalb der Raeumlichkeiten"},
{ID: "A.7.10", Name: "Speichermedien", Category: "physical", CategoryID: "A.7", Description: "Verwaltung von Speichermedien"},
{ID: "A.7.11", Name: "Versorgungseinrichtungen", Category: "physical", CategoryID: "A.7", Description: "Schutz vor Ausfaellen der Versorgungseinrichtungen"},
{ID: "A.7.12", Name: "Verkabelungssicherheit", Category: "physical", CategoryID: "A.7", Description: "Schutz der Verkabelung"},
{ID: "A.7.13", Name: "Instandhaltung von Geraeten", Category: "physical", CategoryID: "A.7", Description: "Korrekte Instandhaltung von Geraeten"},
{ID: "A.7.14", Name: "Sichere Entsorgung", Category: "physical", CategoryID: "A.7", Description: "Sichere Entsorgung oder Wiederverwendung"},
// A.8 Technological Controls (34 controls, showing key ones)
{ID: "A.8.1", Name: "Endbenutzergeraete", Category: "technological", CategoryID: "A.8", Description: "Sicherheit von Endbenutzergeraeten"},
{ID: "A.8.2", Name: "Privilegierte Zugriffsrechte", Category: "technological", CategoryID: "A.8", Description: "Verwaltung privilegierter Zugriffsrechte"},
{ID: "A.8.3", Name: "Informationszugangsbeschraenkung", Category: "technological", CategoryID: "A.8", Description: "Beschraenkung des Zugangs zu Informationen"},
{ID: "A.8.4", Name: "Zugang zu Quellcode", Category: "technological", CategoryID: "A.8", Description: "Sicherer Zugang zu Quellcode"},
{ID: "A.8.5", Name: "Sichere Authentifizierung", Category: "technological", CategoryID: "A.8", Description: "Sichere Authentifizierungstechnologien"},
{ID: "A.8.6", Name: "Kapazitaetsmanagement", Category: "technological", CategoryID: "A.8", Description: "Ueberwachung und Anpassung der Kapazitaet"},
{ID: "A.8.7", Name: "Schutz gegen Malware", Category: "technological", CategoryID: "A.8", Description: "Schutz vor Schadprogrammen"},
{ID: "A.8.8", Name: "Management technischer Schwachstellen", Category: "technological", CategoryID: "A.8", Description: "Identifikation und Behebung von Schwachstellen"},
{ID: "A.8.9", Name: "Konfigurationsmanagement", Category: "technological", CategoryID: "A.8", Description: "Sichere Konfiguration von Systemen"},
{ID: "A.8.10", Name: "Datensicherung", Category: "technological", CategoryID: "A.8", Description: "Erstellen und Testen von Datensicherungen"},
{ID: "A.8.11", Name: "Datenredundanz", Category: "technological", CategoryID: "A.8", Description: "Redundanz von Informationsverarbeitungseinrichtungen"},
{ID: "A.8.12", Name: "Protokollierung", Category: "technological", CategoryID: "A.8", Description: "Aufzeichnung und Ueberwachung von Aktivitaeten"},
{ID: "A.8.13", Name: "Ueberwachung von Aktivitaeten", Category: "technological", CategoryID: "A.8", Description: "Ueberwachung von Netzwerken und Systemen"},
{ID: "A.8.14", Name: "Zeitsynchronisation", Category: "technological", CategoryID: "A.8", Description: "Synchronisation von Uhren"},
{ID: "A.8.15", Name: "Nutzung privilegierter Hilfsprogramme", Category: "technological", CategoryID: "A.8", Description: "Einschraenkung privilegierter Hilfsprogramme"},
{ID: "A.8.16", Name: "Softwareinstallation", Category: "technological", CategoryID: "A.8", Description: "Kontrolle der Softwareinstallation"},
{ID: "A.8.17", Name: "Netzwerksicherheit", Category: "technological", CategoryID: "A.8", Description: "Sicherheit von Netzwerken"},
{ID: "A.8.18", Name: "Netzwerksegmentierung", Category: "technological", CategoryID: "A.8", Description: "Segmentierung von Netzwerken"},
{ID: "A.8.19", Name: "Webfilterung", Category: "technological", CategoryID: "A.8", Description: "Filterung des Webzugangs"},
{ID: "A.8.20", Name: "Kryptografie", Category: "technological", CategoryID: "A.8", Description: "Einsatz kryptografischer Massnahmen"},
{ID: "A.8.21", Name: "Sichere Entwicklung", Category: "technological", CategoryID: "A.8", Description: "Sichere Entwicklungslebenszyklus"},
{ID: "A.8.22", Name: "Sicherheitsanforderungen bei Applikationen", Category: "technological", CategoryID: "A.8", Description: "Sicherheitsanforderungen bei Anwendungen"},
{ID: "A.8.23", Name: "Sichere Systemarchitektur", Category: "technological", CategoryID: "A.8", Description: "Sicherheitsprinzipien in der Systemarchitektur"},
{ID: "A.8.24", Name: "Sicheres Programmieren", Category: "technological", CategoryID: "A.8", Description: "Sichere Programmierpraktiken"},
{ID: "A.8.25", Name: "Sicherheitstests", Category: "technological", CategoryID: "A.8", Description: "Sicherheitstests in der Entwicklung und Abnahme"},
{ID: "A.8.26", Name: "Auslagerung der Entwicklung", Category: "technological", CategoryID: "A.8", Description: "Ueberwachung ausgelagerter Entwicklung"},
{ID: "A.8.27", Name: "Trennung von Umgebungen", Category: "technological", CategoryID: "A.8", Description: "Trennung von Entwicklungs-, Test- und Produktionsumgebungen"},
{ID: "A.8.28", Name: "Aenderungsmanagement", Category: "technological", CategoryID: "A.8", Description: "Formales Aenderungsmanagement"},
{ID: "A.8.29", Name: "Sicherheitstests in der Abnahme", Category: "technological", CategoryID: "A.8", Description: "Durchfuehrung von Sicherheitstests vor Abnahme"},
{ID: "A.8.30", Name: "Datenloeschung", Category: "technological", CategoryID: "A.8", Description: "Sichere Datenloeschung"},
{ID: "A.8.31", Name: "Datenmaskierung", Category: "technological", CategoryID: "A.8", Description: "Techniken zur Datenmaskierung"},
{ID: "A.8.32", Name: "Verhinderung von Datenverlust", Category: "technological", CategoryID: "A.8", Description: "DLP-Massnahmen"},
{ID: "A.8.33", Name: "Testinformationen", Category: "technological", CategoryID: "A.8", Description: "Schutz von Testinformationen"},
{ID: "A.8.34", Name: "Audit-Informationssysteme", Category: "technological", CategoryID: "A.8", Description: "Schutz von Audit-Tools und -systemen"},
}
// Default mappings: which modules cover which ISO controls
var DefaultISOModuleMappings = []ISOModuleMapping{
{
ModuleID: "iso-isms", ModuleName: "ISMS Grundlagen",
ISOControls: []string{"A.5.1", "A.5.2", "A.5.3", "A.5.4", "A.5.35", "A.5.36"},
CoverageLevel: "full",
},
{
ModuleID: "iso-risikobewertung", ModuleName: "Risikobewertung",
ISOControls: []string{"A.5.7", "A.5.8", "A.5.9", "A.5.10", "A.5.12", "A.5.13"},
CoverageLevel: "full",
},
{
ModuleID: "iso-zugangssteuerung", ModuleName: "Zugangssteuerung",
ISOControls: []string{"A.5.15", "A.5.16", "A.5.17", "A.5.18", "A.8.2", "A.8.3", "A.8.5"},
CoverageLevel: "full",
},
{
ModuleID: "iso-kryptografie", ModuleName: "Kryptografie",
ISOControls: []string{"A.8.20", "A.8.21", "A.8.24"},
CoverageLevel: "partial",
},
{
ModuleID: "iso-physisch", ModuleName: "Physische Sicherheit",
ISOControls: []string{"A.7.1", "A.7.2", "A.7.3", "A.7.4", "A.7.5", "A.7.7", "A.7.8"},
CoverageLevel: "full",
},
{
ModuleID: "dsgvo-tom", ModuleName: "Technisch-Organisatorische Massnahmen",
ISOControls: []string{"A.5.34", "A.8.10", "A.8.12", "A.8.30", "A.8.31"},
CoverageLevel: "partial",
},
{
ModuleID: "nis2-incident-response", ModuleName: "NIS2 Incident Response",
ISOControls: []string{"A.5.24", "A.5.25", "A.5.26", "A.5.27", "A.5.28", "A.6.8"},
CoverageLevel: "full",
},
{
ModuleID: "nis2-supply-chain", ModuleName: "NIS2 Lieferkettensicherheit",
ISOControls: []string{"A.5.19", "A.5.20", "A.5.21", "A.5.22", "A.5.23"},
CoverageLevel: "full",
},
{
ModuleID: "nis2-risikomanagement", ModuleName: "NIS2 Risikomanagement",
ISOControls: []string{"A.5.29", "A.5.30", "A.8.6", "A.8.7", "A.8.8", "A.8.9"},
CoverageLevel: "partial",
},
{
ModuleID: "dsgvo-grundlagen", ModuleName: "DSGVO Grundlagen",
ISOControls: []string{"A.5.31", "A.5.34", "A.6.2", "A.6.3"},
CoverageLevel: "partial",
},
}
// GetISOControlByID returns a control by its ID
func GetISOControlByID(id string) (ISOControl, bool) {
for _, c := range ISOControls {
if c.ID == id {
return c, true
}
}
return ISOControl{}, false
}
// GetISOControlsByCategory returns all controls in a category
func GetISOControlsByCategory(categoryID string) []ISOControl {
var result []ISOControl
for _, c := range ISOControls {
if c.CategoryID == categoryID {
result = append(result, c)
}
}
return result
}
// ISOCategorySummary provides a summary per ISO category
type ISOCategorySummary struct {
CategoryID string `json:"category_id"`
CategoryName string `json:"category_name"`
TotalControls int `json:"total_controls"`
CoveredFull int `json:"covered_full"`
CoveredPartial int `json:"covered_partial"`
NotCovered int `json:"not_covered"`
}

View File

@@ -0,0 +1,74 @@
package gci
import "time"
// MockModuleData provides fallback data when academy store is empty
func MockModuleData(tenantID string) []ModuleScore {
return []ModuleScore{
// DSGVO modules
{ModuleID: "dsgvo-grundlagen", ModuleName: "DSGVO Grundlagen", Assigned: 25, Completed: 22, Category: "dsgvo", RiskWeight: 2.0},
{ModuleID: "dsgvo-betroffenenrechte", ModuleName: "Betroffenenrechte", Assigned: 25, Completed: 18, Category: "dsgvo", RiskWeight: 2.5},
{ModuleID: "dsgvo-tom", ModuleName: "Technisch-Organisatorische Massnahmen", Assigned: 20, Completed: 17, Category: "dsgvo", RiskWeight: 2.5},
{ModuleID: "dsgvo-dsfa", ModuleName: "Datenschutz-Folgenabschaetzung", Assigned: 15, Completed: 10, Category: "dsgvo", RiskWeight: 2.0},
{ModuleID: "dsgvo-auftragsverarbeitung", ModuleName: "Auftragsverarbeitung", Assigned: 20, Completed: 16, Category: "dsgvo", RiskWeight: 2.0},
// NIS2 modules
{ModuleID: "nis2-risikomanagement", ModuleName: "NIS2 Risikomanagement", Assigned: 15, Completed: 11, Category: "nis2", RiskWeight: 3.0},
{ModuleID: "nis2-incident-response", ModuleName: "NIS2 Incident Response", Assigned: 15, Completed: 9, Category: "nis2", RiskWeight: 3.0},
{ModuleID: "nis2-supply-chain", ModuleName: "NIS2 Lieferkettensicherheit", Assigned: 10, Completed: 6, Category: "nis2", RiskWeight: 2.0},
{ModuleID: "nis2-management", ModuleName: "NIS2 Geschaeftsleitungspflicht", Assigned: 10, Completed: 8, Category: "nis2", RiskWeight: 3.0},
// ISO 27001 modules
{ModuleID: "iso-isms", ModuleName: "ISMS Grundlagen", Assigned: 20, Completed: 16, Category: "iso27001", RiskWeight: 2.0},
{ModuleID: "iso-risikobewertung", ModuleName: "Risikobewertung", Assigned: 15, Completed: 12, Category: "iso27001", RiskWeight: 2.0},
{ModuleID: "iso-zugangssteuerung", ModuleName: "Zugangssteuerung", Assigned: 20, Completed: 18, Category: "iso27001", RiskWeight: 2.0},
{ModuleID: "iso-kryptografie", ModuleName: "Kryptografie", Assigned: 10, Completed: 7, Category: "iso27001", RiskWeight: 1.5},
{ModuleID: "iso-physisch", ModuleName: "Physische Sicherheit", Assigned: 10, Completed: 9, Category: "iso27001", RiskWeight: 1.0},
// AI Act modules
{ModuleID: "ai-risikokategorien", ModuleName: "KI-Risikokategorien", Assigned: 15, Completed: 12, Category: "ai_act", RiskWeight: 2.5},
{ModuleID: "ai-transparenz", ModuleName: "KI-Transparenzpflichten", Assigned: 15, Completed: 10, Category: "ai_act", RiskWeight: 2.0},
{ModuleID: "ai-hochrisiko", ModuleName: "Hochrisiko-KI-Systeme", Assigned: 10, Completed: 6, Category: "ai_act", RiskWeight: 2.5},
{ModuleID: "ai-governance", ModuleName: "KI-Governance", Assigned: 10, Completed: 7, Category: "ai_act", RiskWeight: 2.0},
}
}
// MockCertificateData provides mock certificate validity dates
func MockCertificateData() map[string]time.Time {
now := time.Now()
return map[string]time.Time{
"dsgvo-grundlagen": now.AddDate(0, 8, 0), // valid 8 months
"dsgvo-betroffenenrechte": now.AddDate(0, 3, 0), // expiring in 3 months
"dsgvo-tom": now.AddDate(0, 10, 0), // valid
"dsgvo-dsfa": now.AddDate(0, -1, 0), // expired 1 month ago
"dsgvo-auftragsverarbeitung": now.AddDate(0, 6, 0),
"nis2-risikomanagement": now.AddDate(0, 5, 0),
"nis2-incident-response": now.AddDate(0, 2, 0), // expiring soon
"nis2-supply-chain": now.AddDate(0, -2, 0), // expired 2 months
"nis2-management": now.AddDate(0, 9, 0),
"iso-isms": now.AddDate(1, 0, 0),
"iso-risikobewertung": now.AddDate(0, 4, 0),
"iso-zugangssteuerung": now.AddDate(0, 11, 0),
"iso-kryptografie": now.AddDate(0, 1, 0), // expiring in 1 month
"iso-physisch": now.AddDate(0, 7, 0),
"ai-risikokategorien": now.AddDate(0, 6, 0),
"ai-transparenz": now.AddDate(0, 3, 0),
"ai-hochrisiko": now.AddDate(0, -3, 0), // expired 3 months
"ai-governance": now.AddDate(0, 5, 0),
}
}
// MockIncidentData returns mock incident counts for adjustment
func MockIncidentData() (openIncidents int, criticalIncidents int) {
return 3, 1
}
// MockGCIHistory returns mock historical GCI snapshots
func MockGCIHistory(tenantID string) []GCISnapshot {
now := time.Now()
return []GCISnapshot{
{TenantID: tenantID, Score: 58.2, MaturityLevel: MaturityReactive, AreaScores: map[string]float64{"dsgvo": 62, "nis2": 48, "iso27001": 60, "ai_act": 55}, CalculatedAt: now.AddDate(0, -3, 0)},
{TenantID: tenantID, Score: 62.5, MaturityLevel: MaturityDefined, AreaScores: map[string]float64{"dsgvo": 65, "nis2": 55, "iso27001": 63, "ai_act": 58}, CalculatedAt: now.AddDate(0, -2, 0)},
{TenantID: tenantID, Score: 67.8, MaturityLevel: MaturityDefined, AreaScores: map[string]float64{"dsgvo": 70, "nis2": 60, "iso27001": 68, "ai_act": 62}, CalculatedAt: now.AddDate(0, -1, 0)},
}
}

View File

@@ -0,0 +1,104 @@
package gci
import "time"
// Level 1: Module Score
type ModuleScore struct {
ModuleID string `json:"module_id"`
ModuleName string `json:"module_name"`
Assigned int `json:"assigned"`
Completed int `json:"completed"`
RawScore float64 `json:"raw_score"` // completions/assigned
ValidityFactor float64 `json:"validity_factor"` // 0.0-1.0
FinalScore float64 `json:"final_score"` // RawScore * ValidityFactor
RiskWeight float64 `json:"risk_weight"` // module criticality weight
Category string `json:"category"` // dsgvo, nis2, iso27001, ai_act
}
// Level 2: Risk-weighted Module Score per regulation area
type RiskWeightedScore struct {
AreaID string `json:"area_id"`
AreaName string `json:"area_name"`
Modules []ModuleScore `json:"modules"`
WeightedSum float64 `json:"weighted_sum"`
TotalWeight float64 `json:"total_weight"`
AreaScore float64 `json:"area_score"` // WeightedSum / TotalWeight
}
// Level 3: Regulation Area Score
type RegulationAreaScore struct {
RegulationID string `json:"regulation_id"` // dsgvo, nis2, iso27001, ai_act
RegulationName string `json:"regulation_name"` // Display name
Score float64 `json:"score"` // 0-100
Weight float64 `json:"weight"` // regulation weight in GCI
WeightedScore float64 `json:"weighted_score"` // Score * Weight
ModuleCount int `json:"module_count"`
CompletedCount int `json:"completed_count"`
}
// Level 4: GCI Result
type GCIResult struct {
TenantID string `json:"tenant_id"`
GCIScore float64 `json:"gci_score"` // 0-100
MaturityLevel string `json:"maturity_level"` // Optimized, Managed, Defined, Reactive, HighRisk
MaturityLabel string `json:"maturity_label"` // German label
CalculatedAt time.Time `json:"calculated_at"`
Profile string `json:"profile"` // default, nis2_relevant, ki_nutzer
AreaScores []RegulationAreaScore `json:"area_scores"`
CriticalityMult float64 `json:"criticality_multiplier"`
IncidentAdj float64 `json:"incident_adjustment"`
AuditTrail []AuditEntry `json:"audit_trail"`
}
// GCI Breakdown with all 4 levels
type GCIBreakdown struct {
GCIResult
Level1Modules []ModuleScore `json:"level1_modules"`
Level2Areas []RiskWeightedScore `json:"level2_areas"`
}
// MaturityLevel constants
const (
MaturityOptimized = "OPTIMIZED"
MaturityManaged = "MANAGED"
MaturityDefined = "DEFINED"
MaturityReactive = "REACTIVE"
MaturityHighRisk = "HIGH_RISK"
)
// Maturity level labels (German)
var MaturityLabels = map[string]string{
MaturityOptimized: "Optimiert",
MaturityManaged: "Gesteuert",
MaturityDefined: "Definiert",
MaturityReactive: "Reaktiv",
MaturityHighRisk: "Hohes Risiko",
}
// AuditEntry for score transparency
type AuditEntry struct {
Timestamp time.Time `json:"timestamp"`
Factor string `json:"factor"`
Description string `json:"description"`
Value float64 `json:"value"`
Impact string `json:"impact"` // positive, negative, neutral
}
// ComplianceMatrixEntry maps roles to regulations
type ComplianceMatrixEntry struct {
Role string `json:"role"`
RoleName string `json:"role_name"`
Regulations map[string]float64 `json:"regulations"` // regulation_id -> score
OverallScore float64 `json:"overall_score"`
RequiredModules int `json:"required_modules"`
CompletedModules int `json:"completed_modules"`
}
// GCI History snapshot
type GCISnapshot struct {
TenantID string `json:"tenant_id"`
Score float64 `json:"score"`
MaturityLevel string `json:"maturity_level"`
AreaScores map[string]float64 `json:"area_scores"`
CalculatedAt time.Time `json:"calculated_at"`
}

View File

@@ -0,0 +1,118 @@
package gci
// NIS2Role defines a NIS2 role classification
type NIS2Role struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
MandatoryModules []string `json:"mandatory_modules"`
Priority int `json:"priority"` // 1=highest
}
// NIS2RoleAssignment represents a user's NIS2 role
type NIS2RoleAssignment struct {
TenantID string `json:"tenant_id"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
RoleID string `json:"role_id"`
RoleName string `json:"role_name"`
AssignedAt string `json:"assigned_at"`
}
// NIS2 role definitions
var NIS2Roles = map[string]NIS2Role{
"N1": {
ID: "N1",
Name: "Geschaeftsleitung",
Description: "Leitungsorgane mit persoenlicher Haftung gemaess NIS2 Art. 20",
Priority: 1,
MandatoryModules: []string{
"nis2-management",
"nis2-risikomanagement",
"dsgvo-grundlagen",
"iso-isms",
},
},
"N2": {
ID: "N2",
Name: "IT-Sicherheit / CISO",
Description: "Verantwortliche fuer IT-Sicherheit und Cybersecurity",
Priority: 2,
MandatoryModules: []string{
"nis2-risikomanagement",
"nis2-incident-response",
"nis2-supply-chain",
"iso-zugangssteuerung",
"iso-kryptografie",
},
},
"N3": {
ID: "N3",
Name: "Kritische Funktionen",
Description: "Mitarbeiter in kritischen Geschaeftsprozessen",
Priority: 3,
MandatoryModules: []string{
"nis2-risikomanagement",
"nis2-incident-response",
"dsgvo-tom",
"iso-zugangssteuerung",
},
},
"N4": {
ID: "N4",
Name: "Allgemeine Mitarbeiter",
Description: "Alle Mitarbeiter mit IT-Zugang",
Priority: 4,
MandatoryModules: []string{
"nis2-risikomanagement",
"dsgvo-grundlagen",
"iso-isms",
},
},
"N5": {
ID: "N5",
Name: "Incident Response Team",
Description: "Mitglieder des IRT/CSIRT gemaess NIS2 Art. 21",
Priority: 2,
MandatoryModules: []string{
"nis2-incident-response",
"nis2-risikomanagement",
"nis2-supply-chain",
"iso-zugangssteuerung",
"iso-kryptografie",
"iso-isms",
},
},
}
// GetNIS2Role returns a NIS2 role by ID
func GetNIS2Role(roleID string) (NIS2Role, bool) {
r, ok := NIS2Roles[roleID]
return r, ok
}
// ListNIS2Roles returns all NIS2 roles sorted by priority
func ListNIS2Roles() []NIS2Role {
roles := []NIS2Role{}
// Return in priority order
order := []string{"N1", "N2", "N5", "N3", "N4"}
for _, id := range order {
if r, ok := NIS2Roles[id]; ok {
roles = append(roles, r)
}
}
return roles
}
// MockNIS2RoleAssignments returns mock role assignments
func MockNIS2RoleAssignments(tenantID string) []NIS2RoleAssignment {
return []NIS2RoleAssignment{
{TenantID: tenantID, UserID: "user-001", UserName: "Dr. Schmidt", RoleID: "N1", RoleName: "Geschaeftsleitung", AssignedAt: "2025-06-01"},
{TenantID: tenantID, UserID: "user-002", UserName: "M. Weber", RoleID: "N2", RoleName: "IT-Sicherheit / CISO", AssignedAt: "2025-06-01"},
{TenantID: tenantID, UserID: "user-003", UserName: "S. Mueller", RoleID: "N5", RoleName: "Incident Response Team", AssignedAt: "2025-07-15"},
{TenantID: tenantID, UserID: "user-004", UserName: "K. Fischer", RoleID: "N3", RoleName: "Kritische Funktionen", AssignedAt: "2025-08-01"},
{TenantID: tenantID, UserID: "user-005", UserName: "L. Braun", RoleID: "N3", RoleName: "Kritische Funktionen", AssignedAt: "2025-08-01"},
{TenantID: tenantID, UserID: "user-006", UserName: "A. Schwarz", RoleID: "N4", RoleName: "Allgemeine Mitarbeiter", AssignedAt: "2025-09-01"},
{TenantID: tenantID, UserID: "user-007", UserName: "T. Wagner", RoleID: "N4", RoleName: "Allgemeine Mitarbeiter", AssignedAt: "2025-09-01"},
}
}

View File

@@ -0,0 +1,147 @@
package gci
import "math"
// NIS2Score represents the NIS2-specific compliance score
type NIS2Score struct {
TenantID string `json:"tenant_id"`
OverallScore float64 `json:"overall_score"`
MaturityLevel string `json:"maturity_level"`
MaturityLabel string `json:"maturity_label"`
AreaScores []NIS2AreaScore `json:"area_scores"`
RoleCompliance []NIS2RoleScore `json:"role_compliance"`
}
// NIS2AreaScore represents a NIS2 compliance area
type NIS2AreaScore struct {
AreaID string `json:"area_id"`
AreaName string `json:"area_name"`
Score float64 `json:"score"`
Weight float64 `json:"weight"`
ModuleIDs []string `json:"module_ids"`
}
// NIS2RoleScore represents completion per NIS2 role
type NIS2RoleScore struct {
RoleID string `json:"role_id"`
RoleName string `json:"role_name"`
AssignedUsers int `json:"assigned_users"`
CompletionRate float64 `json:"completion_rate"`
MandatoryTotal int `json:"mandatory_total"`
MandatoryDone int `json:"mandatory_done"`
}
// NIS2 scoring areas with weights
// NIS2Score = 25% Management + 25% Incident + 30% IT Security + 20% Supply Chain
var nis2Areas = []struct {
ID string
Name string
Weight float64
ModuleIDs []string
}{
{
ID: "management", Name: "Management & Governance", Weight: 0.25,
ModuleIDs: []string{"nis2-management", "dsgvo-grundlagen", "iso-isms"},
},
{
ID: "incident", Name: "Vorfallsbehandlung", Weight: 0.25,
ModuleIDs: []string{"nis2-incident-response"},
},
{
ID: "it_security", Name: "IT-Sicherheit", Weight: 0.30,
ModuleIDs: []string{"nis2-risikomanagement", "iso-zugangssteuerung", "iso-kryptografie"},
},
{
ID: "supply_chain", Name: "Lieferkettensicherheit", Weight: 0.20,
ModuleIDs: []string{"nis2-supply-chain", "dsgvo-auftragsverarbeitung"},
},
}
// CalculateNIS2Score computes the NIS2-specific compliance score
func CalculateNIS2Score(tenantID string) *NIS2Score {
modules := MockModuleData(tenantID)
moduleMap := map[string]ModuleScore{}
for _, m := range modules {
moduleMap[m.ModuleID] = m
}
areaScores := []NIS2AreaScore{}
totalWeighted := 0.0
for _, area := range nis2Areas {
areaScore := NIS2AreaScore{
AreaID: area.ID,
AreaName: area.Name,
Weight: area.Weight,
ModuleIDs: area.ModuleIDs,
}
scoreSum := 0.0
count := 0
for _, modID := range area.ModuleIDs {
if m, ok := moduleMap[modID]; ok {
if m.Assigned > 0 {
scoreSum += float64(m.Completed) / float64(m.Assigned) * 100
}
count++
}
}
if count > 0 {
areaScore.Score = math.Round(scoreSum/float64(count)*10) / 10
}
totalWeighted += areaScore.Score * areaScore.Weight
areaScores = append(areaScores, areaScore)
}
overallScore := math.Round(totalWeighted*10) / 10
// Calculate role compliance
roleAssignments := MockNIS2RoleAssignments(tenantID)
roleScores := calculateNIS2RoleScores(roleAssignments, moduleMap)
return &NIS2Score{
TenantID: tenantID,
OverallScore: overallScore,
MaturityLevel: determineMaturityLevel(overallScore),
MaturityLabel: MaturityLabels[determineMaturityLevel(overallScore)],
AreaScores: areaScores,
RoleCompliance: roleScores,
}
}
func calculateNIS2RoleScores(assignments []NIS2RoleAssignment, moduleMap map[string]ModuleScore) []NIS2RoleScore {
// Count users per role
roleCounts := map[string]int{}
for _, a := range assignments {
roleCounts[a.RoleID]++
}
scores := []NIS2RoleScore{}
for roleID, role := range NIS2Roles {
rs := NIS2RoleScore{
RoleID: roleID,
RoleName: role.Name,
AssignedUsers: roleCounts[roleID],
MandatoryTotal: len(role.MandatoryModules),
}
completionSum := 0.0
for _, modID := range role.MandatoryModules {
if m, ok := moduleMap[modID]; ok {
if m.Assigned > 0 {
rate := float64(m.Completed) / float64(m.Assigned)
completionSum += rate
if rate >= 0.8 { // 80%+ = considered done
rs.MandatoryDone++
}
}
}
}
if rs.MandatoryTotal > 0 {
rs.CompletionRate = math.Round(completionSum/float64(rs.MandatoryTotal)*100*10) / 10
}
scores = append(scores, rs)
}
return scores
}

View File

@@ -0,0 +1,59 @@
package gci
import (
"math"
"time"
)
const (
// GracePeriodDays is the number of days after expiry during which
// the certificate still contributes (with declining factor)
GracePeriodDays = 180
// DecayStartDays is how many days before expiry the linear decay begins
DecayStartDays = 180
)
// CalculateValidityFactor computes the validity factor for a certificate
// based on its expiry date.
//
// Rules:
// - Certificate not yet expiring (>6 months): factor = 1.0
// - Certificate expiring within 6 months: linear decay from 1.0 to 0.5
// - Certificate expired: linear decay from 0.5 to 0.0 over grace period
// - Certificate expired beyond grace period: factor = 0.0
func CalculateValidityFactor(validUntil time.Time, now time.Time) float64 {
daysUntilExpiry := validUntil.Sub(now).Hours() / 24.0
if daysUntilExpiry > float64(DecayStartDays) {
// Not yet in decay window
return 1.0
}
if daysUntilExpiry > 0 {
// In pre-expiry decay window: linear from 1.0 to 0.5
fraction := daysUntilExpiry / float64(DecayStartDays)
return 0.5 + 0.5*fraction
}
// Certificate is expired
daysExpired := -daysUntilExpiry
if daysExpired > float64(GracePeriodDays) {
return 0.0
}
// In grace period: linear from 0.5 to 0.0
fraction := 1.0 - (daysExpired / float64(GracePeriodDays))
return math.Max(0, 0.5*fraction)
}
// IsExpired returns true if the certificate is past its validity date
func IsExpired(validUntil time.Time, now time.Time) bool {
return now.After(validUntil)
}
// IsExpiringSoon returns true if the certificate expires within the decay window
func IsExpiringSoon(validUntil time.Time, now time.Time) bool {
daysUntil := validUntil.Sub(now).Hours() / 24.0
return daysUntil > 0 && daysUntil <= float64(DecayStartDays)
}

View File

@@ -0,0 +1,78 @@
package gci
// WeightProfile defines regulation weights for different compliance profiles
type WeightProfile struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Weights map[string]float64 `json:"weights"` // regulation_id -> weight (0.0-1.0)
}
// Default weight profiles
var DefaultProfiles = map[string]WeightProfile{
"default": {
ID: "default",
Name: "Standard",
Description: "Ausgewogenes Profil fuer allgemeine Compliance",
Weights: map[string]float64{
"dsgvo": 0.30,
"nis2": 0.25,
"iso27001": 0.25,
"ai_act": 0.20,
},
},
"nis2_relevant": {
ID: "nis2_relevant",
Name: "NIS2-relevant",
Description: "Fuer Betreiber kritischer Infrastrukturen",
Weights: map[string]float64{
"dsgvo": 0.25,
"nis2": 0.35,
"iso27001": 0.25,
"ai_act": 0.15,
},
},
"ki_nutzer": {
ID: "ki_nutzer",
Name: "KI-Nutzer",
Description: "Fuer Organisationen mit KI-Einsatz",
Weights: map[string]float64{
"dsgvo": 0.25,
"nis2": 0.25,
"iso27001": 0.20,
"ai_act": 0.30,
},
},
}
// ModuleRiskWeights defines risk criticality per module type
var ModuleRiskWeights = map[string]float64{
"incident_response": 3.0,
"management_awareness": 3.0,
"data_protection": 2.5,
"it_security": 2.5,
"supply_chain": 2.0,
"risk_assessment": 2.0,
"access_control": 2.0,
"business_continuity": 2.0,
"employee_training": 1.5,
"documentation": 1.5,
"physical_security": 1.0,
"general": 1.0,
}
// GetProfile returns a weight profile by ID, defaulting to "default"
func GetProfile(profileID string) WeightProfile {
if p, ok := DefaultProfiles[profileID]; ok {
return p
}
return DefaultProfiles["default"]
}
// GetModuleRiskWeight returns the risk weight for a module category
func GetModuleRiskWeight(category string) float64 {
if w, ok := ModuleRiskWeights[category]; ok {
return w
}
return 1.0
}

View File

@@ -45,7 +45,7 @@ func (s *Service) GenerateDSFA(ctx context.Context, context map[string]interface
}
// Build prompt with context and RAG sources
prompt := s.buildDSFAPrompt(context, ragSources)
_ = s.buildDSFAPrompt(context, ragSources)
// In production, this would call the Anthropic API
// response, err := s.callAnthropicAPI(ctx, prompt)

View File

@@ -88,7 +88,7 @@ func (s *Service) getMockSearchResults(query string, topK int) []SearchResult {
// DSGVO Articles
{
ID: "dsgvo-art-5",
Content: "Art. 5 DSGVO - Grundsätze für die Verarbeitung personenbezogener Daten\n\n(1) Personenbezogene Daten müssen:\na) auf rechtmäßige Weise, nach Treu und Glauben und in einer für die betroffene Person nachvollziehbaren Weise verarbeitet werden (Rechtmäßigkeit, Verarbeitung nach Treu und Glauben, Transparenz");\nb) für festgelegte, eindeutige und legitime Zwecke erhoben werden und dürfen nicht in einer mit diesen Zwecken nicht zu vereinbarenden Weise weiterverarbeitet werden (Zweckbindung");\nc) dem Zweck angemessen und erheblich sowie auf das für die Zwecke der Verarbeitung notwendige Maß beschränkt sein (Datenminimierung");",
Content: "Art. 5 DSGVO - Grundsaetze fuer die Verarbeitung personenbezogener Daten\n\n(1) Personenbezogene Daten muessen:\na) auf rechtmaessige Weise, nach Treu und Glauben und in einer fuer die betroffene Person nachvollziehbaren Weise verarbeitet werden (Rechtmaessigkeit, Verarbeitung nach Treu und Glauben, Transparenz);\nb) fuer festgelegte, eindeutige und legitime Zwecke erhoben werden und duerfen nicht in einer mit diesen Zwecken nicht zu vereinbarenden Weise weiterverarbeitet werden (Zweckbindung);\nc) dem Zweck angemessen und erheblich sowie auf das fuer die Zwecke der Verarbeitung notwendige Mass beschraenkt sein (Datenminimierung);",
Source: "DSGVO",
Score: 0.95,
Metadata: map[string]string{

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ export default function ArchitecturePage() {
databases: ['PostgreSQL', 'Qdrant']
}}
relatedPages={[
{ name: 'Compliance Hub', href: '/compliance/hub', description: 'Compliance-Module' },
{ name: 'Compliance Hub', href: '/sdk/compliance-hub', description: 'Compliance-Module' },
{ name: 'AI Hub', href: '/ai', description: 'KI-Module' },
]}
/>

View File

@@ -1,831 +0,0 @@
'use client'
/**
* EU-AI-Act Risk Classification Page
*
* Self-assessment and documentation of AI risk categories according to EU AI Act.
* Provides module-by-module risk assessment, warning lines, and exportable documentation.
*/
import { useState } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// =============================================================================
// TYPES
// =============================================================================
type RiskLevel = 'unacceptable' | 'high' | 'limited' | 'minimal'
interface ModuleAssessment {
id: string
name: string
description: string
riskLevel: RiskLevel
justification: string
humanInLoop: boolean
transparencyMeasures: string[]
aiActArticle: string
}
interface WarningLine {
id: string
title: string
description: string
wouldTrigger: RiskLevel
currentStatus: 'safe' | 'approaching' | 'violated'
recommendation: string
}
// =============================================================================
// DATA - Breakpilot Module Assessments
// =============================================================================
const MODULE_ASSESSMENTS: ModuleAssessment[] = [
{
id: 'text-suggestions',
name: 'Textvorschlaege / Formulierhilfen',
description: 'KI-generierte Textvorschlaege fuer Gutachten und Feedback',
riskLevel: 'minimal',
justification: 'Reine Assistenzfunktion ohne Entscheidungswirkung. Lehrer editieren und finalisieren alle Texte.',
humanInLoop: true,
transparencyMeasures: ['KI-Label auf generierten Texten', 'Editierbare Vorschlaege'],
aiActArticle: 'Art. 69 (Freiwillige Verhaltenskodizes)',
},
{
id: 'rag-sources',
name: 'RAG-basierte Quellenanzeige',
description: 'Retrieval Augmented Generation fuer Lehrplan- und Erwartungshorizont-Referenzen',
riskLevel: 'minimal',
justification: 'Zitierende Referenzfunktion. Zeigt nur offizielle Quellen an, trifft keine Entscheidungen.',
humanInLoop: true,
transparencyMeasures: ['Quellenangaben', 'Direkte Links zu Originaldokumenten'],
aiActArticle: 'Art. 69 (Freiwillige Verhaltenskodizes)',
},
{
id: 'correction-suggestions',
name: 'Korrektur-Vorschlaege',
description: 'KI-Vorschlaege fuer Bewertungskriterien und Punktevergabe',
riskLevel: 'limited',
justification: 'Vorschlaege ohne bindende Wirkung. Lehrkraft behaelt vollstaendige Entscheidungshoheit.',
humanInLoop: true,
transparencyMeasures: [
'Klare Kennzeichnung als KI-Vorschlag',
'Begruendung fuer jeden Vorschlag',
'Einfache Ueberschreibung moeglich',
],
aiActArticle: 'Art. 52 (Transparenzpflichten)',
},
{
id: 'eh-matching',
name: 'Erwartungshorizont-Abgleich',
description: 'Automatischer Abgleich von Schuelerantworten mit Erwartungshorizonten',
riskLevel: 'limited',
justification: 'Empfehlung, keine Entscheidung. Zeigt Uebereinstimmungen auf, bewertet nicht eigenstaendig.',
humanInLoop: true,
transparencyMeasures: [
'Visualisierung der Matching-Logik',
'Confidence-Score angezeigt',
'Manuelle Korrektur jederzeit moeglich',
],
aiActArticle: 'Art. 52 (Transparenzpflichten)',
},
{
id: 'report-drafts',
name: 'Zeugnis-Textentwurf',
description: 'KI-generierte Entwuerfe fuer Zeugnistexte und Beurteilungen',
riskLevel: 'limited',
justification: 'Entwurf, der von der Lehrkraft finalisiert wird. Keine automatische Uebernahme.',
humanInLoop: true,
transparencyMeasures: [
'Entwurf-Wasserzeichen',
'Pflicht zur manuellen Freigabe',
'Vollstaendig editierbar',
],
aiActArticle: 'Art. 52 (Transparenzpflichten)',
},
{
id: 'edu-search',
name: 'Bildungssuche (edu-search)',
description: 'Semantische Suche in Lehrplaenen und Bildungsmaterialien',
riskLevel: 'minimal',
justification: 'Informationsretrieval ohne Bewertungsfunktion. Reine Suchfunktion.',
humanInLoop: true,
transparencyMeasures: ['Quellenangaben', 'Ranking-Transparenz'],
aiActArticle: 'Art. 69 (Freiwillige Verhaltenskodizes)',
},
]
// =============================================================================
// DATA - Warning Lines (What we must never build)
// =============================================================================
const WARNING_LINES: WarningLine[] = [
{
id: 'auto-grading',
title: 'Automatische Notenvergabe',
description: 'KI berechnet und vergibt Noten ohne menschliche Pruefung',
wouldTrigger: 'high',
currentStatus: 'safe',
recommendation: 'Noten immer als Vorschlag, nie als finale Entscheidung',
},
{
id: 'student-classification',
title: 'Schuelerklassifikation',
description: 'Automatische Einteilung in Leistungsgruppen (leistungsstark/schwach)',
wouldTrigger: 'high',
currentStatus: 'safe',
recommendation: 'Keine automatische Kategorisierung von Schuelern implementieren',
},
{
id: 'promotion-decisions',
title: 'Versetzungsentscheidungen',
description: 'Automatisierte Logik fuer Versetzung/Nichtversetzung',
wouldTrigger: 'high',
currentStatus: 'safe',
recommendation: 'Versetzungsentscheidungen ausschliesslich bei Lehrkraeften belassen',
},
{
id: 'unreviewed-assessments',
title: 'Ungeprueft freigegebene Bewertungen',
description: 'KI-Bewertungen ohne menschliche Kontrolle an Schueler/Eltern',
wouldTrigger: 'high',
currentStatus: 'safe',
recommendation: 'Immer manuellen Freigabe-Schritt vor Veroeffentlichung',
},
{
id: 'behavioral-profiling',
title: 'Verhaltensprofilierung',
description: 'Erstellung von Persoenlichkeits- oder Verhaltensprofilen von Schuelern',
wouldTrigger: 'unacceptable',
currentStatus: 'safe',
recommendation: 'Keine Verhaltensanalyse oder Profiling implementieren',
},
{
id: 'algorithmic-optimization',
title: 'Algorithmische Schuloptimierung',
description: 'KI optimiert Schulentscheidungen (Klassenzuteilung, Ressourcen)',
wouldTrigger: 'high',
currentStatus: 'safe',
recommendation: 'Schulorganisatorische Entscheidungen bei Menschen belassen',
},
{
id: 'auto-accept',
title: 'Auto-Accept Funktionen',
description: 'Ein-Klick-Uebernahme von KI-Vorschlaegen ohne Pruefung',
wouldTrigger: 'high',
currentStatus: 'safe',
recommendation: 'Immer bewusste Bestaetigungsschritte einbauen',
},
{
id: 'emotion-detection',
title: 'Emotionserkennung',
description: 'Analyse von Emotionen oder psychischem Zustand von Schuelern',
wouldTrigger: 'unacceptable',
currentStatus: 'safe',
recommendation: 'Keine biometrische oder emotionale Analyse',
},
]
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
const getRiskLevelInfo = (level: RiskLevel) => {
switch (level) {
case 'unacceptable':
return {
label: 'Unzulaessig',
color: 'bg-black text-white',
borderColor: 'border-black',
description: 'Verboten nach EU-AI-Act',
}
case 'high':
return {
label: 'Hoch',
color: 'bg-red-600 text-white',
borderColor: 'border-red-600',
description: 'Strenge Anforderungen, Konformitaetsbewertung',
}
case 'limited':
return {
label: 'Begrenzt',
color: 'bg-amber-500 text-white',
borderColor: 'border-amber-500',
description: 'Transparenzpflichten',
}
case 'minimal':
return {
label: 'Minimal',
color: 'bg-green-600 text-white',
borderColor: 'border-green-600',
description: 'Freiwillige Verhaltenskodizes',
}
}
}
const getStatusInfo = (status: 'safe' | 'approaching' | 'violated') => {
switch (status) {
case 'safe':
return { label: 'Sicher', color: 'bg-green-100 text-green-700', icon: '✓' }
case 'approaching':
return { label: 'Annaehernd', color: 'bg-amber-100 text-amber-700', icon: '⚠' }
case 'violated':
return { label: 'Verletzt', color: 'bg-red-100 text-red-700', icon: '✗' }
}
}
// =============================================================================
// COMPONENT
// =============================================================================
export default function AIActClassificationPage() {
const [activeTab, setActiveTab] = useState<'overview' | 'modules' | 'warnings' | 'documentation'>('overview')
const [expandedModule, setExpandedModule] = useState<string | null>(null)
// Calculate statistics
const stats = {
totalModules: MODULE_ASSESSMENTS.length,
minimalRisk: MODULE_ASSESSMENTS.filter((m) => m.riskLevel === 'minimal').length,
limitedRisk: MODULE_ASSESSMENTS.filter((m) => m.riskLevel === 'limited').length,
highRisk: MODULE_ASSESSMENTS.filter((m) => m.riskLevel === 'high').length,
humanInLoop: MODULE_ASSESSMENTS.filter((m) => m.humanInLoop).length,
warningsTotal: WARNING_LINES.length,
warningsSafe: WARNING_LINES.filter((w) => w.currentStatus === 'safe').length,
}
const generateMemo = () => {
const date = new Date().toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
const memo = `
================================================================================
EU-AI-ACT RISIKOKLASSIFIZIERUNG - BREAKPILOT
================================================================================
Erstellungsdatum: ${date}
Version: 1.0
Verantwortlich: Breakpilot GmbH
--------------------------------------------------------------------------------
1. ZUSAMMENFASSUNG
--------------------------------------------------------------------------------
System: Breakpilot KI-Assistenzsystem fuer Bildung
Gesamtrisikokategorie: LIMITED RISK (Art. 52 EU-AI-Act)
Begruendung:
- KI liefert ausschliesslich Vorschlaege und Entwuerfe
- Kein automatisiertes Entscheiden ueber Schueler
- Mensch-in-the-Loop ist technisch erzwungen
- Keine Schuelerklassifikation oder Profiling
- Alle paedagogischen Entscheidungen verbleiben bei Lehrkraeften
--------------------------------------------------------------------------------
2. MODUL-BEWERTUNG
--------------------------------------------------------------------------------
${MODULE_ASSESSMENTS.map(
(m) => `
${m.name}
Risikostufe: ${getRiskLevelInfo(m.riskLevel).label.toUpperCase()}
Begruendung: ${m.justification}
Human-in-Loop: ${m.humanInLoop ? 'JA' : 'NEIN'}
AI-Act Artikel: ${m.aiActArticle}
`
).join('')}
--------------------------------------------------------------------------------
3. TRANSPARENZMASSNAHMEN
--------------------------------------------------------------------------------
Alle KI-generierten Inhalte sind:
- Klar als KI-Vorschlag gekennzeichnet
- Vollstaendig editierbar durch die Lehrkraft
- Mit Quellenangaben versehen (wo zutreffend)
- Erst nach manueller Freigabe wirksam
Zusaetzliche UI-Hinweise:
- "Dieser Text wurde durch KI vorgeschlagen"
- "Endverantwortung liegt bei der Lehrkraft"
- Confidence-Scores wo relevant
--------------------------------------------------------------------------------
4. HUMAN-IN-THE-LOOP GARANTIEN
--------------------------------------------------------------------------------
Technisch erzwungene Massnahmen:
- Kein Auto-Accept fuer KI-Vorschlaege
- Kein 1-Click-Bewerten
- Pflicht-Bestaetigung vor Freigabe
- Audit-Trail aller Aenderungen
--------------------------------------------------------------------------------
5. WARNLINIEN (NICHT IMPLEMENTIEREN)
--------------------------------------------------------------------------------
${WARNING_LINES.map(
(w) => `
[${getStatusInfo(w.currentStatus).icon}] ${w.title}
Wuerde ausloesen: ${getRiskLevelInfo(w.wouldTrigger).label}
Status: ${getStatusInfo(w.currentStatus).label}
`
).join('')}
--------------------------------------------------------------------------------
6. RECHTLICHE EINORDNUNG
--------------------------------------------------------------------------------
Breakpilot faellt NICHT unter die High-Risk Kategorie des EU-AI-Act, da:
1. Keine automatisierten Entscheidungen ueber natuerliche Personen
2. Keine Bewertung von Schuelern ohne menschliche Kontrolle
3. Keine Zugangs- oder Selektionsentscheidungen
4. Reine Assistenzfunktion mit Human-in-the-Loop
Die Transparenzpflichten nach Art. 52 werden durch entsprechende
UI-Kennzeichnungen und Nutzerinformationen erfuellt.
--------------------------------------------------------------------------------
7. MANAGEMENT-STATEMENT
--------------------------------------------------------------------------------
"Breakpilot ist ein KI-Assistenzsystem mit begrenztem Risiko gemaess EU-AI-Act.
Die KI trifft keine Entscheidungen, sondern unterstuetzt Lehrkraefte transparent
und nachvollziehbar. Alle paedagogischen und rechtlichen Entscheidungen
verbleiben beim Menschen."
================================================================================
Dieses Dokument dient der internen Compliance-Dokumentation und kann
Auditoren auf Anfrage vorgelegt werden.
================================================================================
`
return memo
}
const downloadMemo = () => {
const memo = generateMemo()
const blob = new Blob([memo], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `breakpilot-ai-act-klassifizierung-${new Date().toISOString().split('T')[0]}.txt`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const tabs = [
{ id: 'overview', name: 'Uebersicht', icon: '📊' },
{ id: 'modules', name: 'Module', icon: '🧩' },
{ id: 'warnings', name: 'Warnlinien', icon: '⚠️' },
{ id: 'documentation', name: 'Dokumentation', icon: '📄' },
]
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-white border-b border-slate-200">
<div className="max-w-7xl mx-auto px-6 py-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center">
<span className="text-2xl">🤖</span>
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">EU-AI-Act Klassifizierung</h1>
<p className="text-slate-600">Risikoklassifizierung und Compliance-Dokumentation</p>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-6 py-6">
{/* Page Purpose */}
<PagePurpose
title="KI-Risikoklassifizierung nach EU-AI-Act"
purpose="Selbstbewertung und Dokumentation der Risikokategorien aller KI-Module gemaess EU-AI-Act. Definiert Warnlinien fuer Features, die nicht implementiert werden duerfen."
audience={['Management', 'DSB', 'Compliance Officer', 'Auditor', 'Investoren']}
gdprArticles={['EU-AI-Act Art. 52', 'EU-AI-Act Art. 69', 'EU-AI-Act Anhang III']}
/>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Module gesamt</div>
<div className="text-2xl font-bold text-slate-900">{stats.totalModules}</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Minimal Risk</div>
<div className="text-2xl font-bold text-green-600">{stats.minimalRisk}</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Limited Risk</div>
<div className="text-2xl font-bold text-amber-600">{stats.limitedRisk}</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Human-in-Loop</div>
<div className="text-2xl font-bold text-blue-600">{stats.humanInLoop}/{stats.totalModules}</div>
</div>
</div>
{/* Classification Banner */}
<div className="mt-6 bg-gradient-to-r from-amber-50 to-amber-100 border border-amber-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-full bg-amber-200 flex items-center justify-center flex-shrink-0">
<span className="text-3xl"></span>
</div>
<div>
<h2 className="text-xl font-bold text-amber-900">Gesamtklassifizierung: LIMITED RISK</h2>
<p className="text-amber-800 mt-1">
Breakpilot ist ein KI-Assistenzsystem mit <strong>begrenztem Risiko</strong> gemaess EU-AI-Act (Art. 52).
Es gelten Transparenzpflichten, aber keine Konformitaetsbewertung.
</p>
<div className="flex flex-wrap gap-2 mt-3">
<span className="px-3 py-1 bg-amber-200 text-amber-800 rounded-full text-sm font-medium">
Art. 52 Transparenz
</span>
<span className="px-3 py-1 bg-green-200 text-green-800 rounded-full text-sm font-medium">
Human-in-the-Loop
</span>
<span className="px-3 py-1 bg-blue-200 text-blue-800 rounded-full text-sm font-medium">
Assistiv, nicht autonom
</span>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 mt-6 border-b border-slate-200">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-slate-600 hover:text-slate-900'
}`}
>
<span className="mr-2">{tab.icon}</span>
{tab.name}
</button>
))}
</div>
{/* Tab Content */}
<div className="mt-6">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Risk Level Pyramid */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">EU-AI-Act Risikopyramide</h3>
<div className="space-y-3">
{/* Unacceptable */}
<div className="flex items-center gap-4">
<div className="w-32 text-right">
<span className="px-3 py-1 bg-black text-white text-xs rounded font-medium">
Unzulaessig
</span>
</div>
<div className="flex-1 h-8 bg-slate-100 rounded relative overflow-hidden">
<div className="absolute inset-y-0 left-0 w-0 bg-black rounded" />
<span className="absolute inset-0 flex items-center justify-center text-xs text-slate-500">
0 Module - Social Scoring, Manipulation verboten
</span>
</div>
</div>
{/* High */}
<div className="flex items-center gap-4">
<div className="w-32 text-right">
<span className="px-3 py-1 bg-red-600 text-white text-xs rounded font-medium">
Hoch
</span>
</div>
<div className="flex-1 h-8 bg-slate-100 rounded relative overflow-hidden">
<div className="absolute inset-y-0 left-0 w-0 bg-red-600 rounded" />
<span className="absolute inset-0 flex items-center justify-center text-xs text-slate-500">
0 Module - Keine automatischen Entscheidungen
</span>
</div>
</div>
{/* Limited */}
<div className="flex items-center gap-4">
<div className="w-32 text-right">
<span className="px-3 py-1 bg-amber-500 text-white text-xs rounded font-medium">
Begrenzt
</span>
</div>
<div className="flex-1 h-8 bg-slate-100 rounded relative overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-amber-500 rounded transition-all"
style={{ width: `${(stats.limitedRisk / stats.totalModules) * 100}%` }}
/>
<span className="absolute inset-0 flex items-center justify-center text-xs font-medium text-slate-700">
{stats.limitedRisk} Module - Transparenzpflichten
</span>
</div>
</div>
{/* Minimal */}
<div className="flex items-center gap-4">
<div className="w-32 text-right">
<span className="px-3 py-1 bg-green-600 text-white text-xs rounded font-medium">
Minimal
</span>
</div>
<div className="flex-1 h-8 bg-slate-100 rounded relative overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-green-600 rounded transition-all"
style={{ width: `${(stats.minimalRisk / stats.totalModules) * 100}%` }}
/>
<span className="absolute inset-0 flex items-center justify-center text-xs font-medium text-slate-700">
{stats.minimalRisk} Module - Freiwillige Kodizes
</span>
</div>
</div>
</div>
</div>
{/* Key Arguments */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Kernargumente fuer Limited Risk</h3>
<div className="grid md:grid-cols-2 gap-4">
<div className="flex items-start gap-3 p-4 bg-green-50 rounded-lg">
<span className="text-xl"></span>
<div>
<div className="font-medium text-green-900">Assistiv, nicht autonom</div>
<div className="text-sm text-green-700">KI liefert Vorschlaege, keine Entscheidungen</div>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-green-50 rounded-lg">
<span className="text-xl"></span>
<div>
<div className="font-medium text-green-900">Human-in-the-Loop</div>
<div className="text-sm text-green-700">Lehrkraft hat immer das letzte Wort</div>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-green-50 rounded-lg">
<span className="text-xl"></span>
<div>
<div className="font-medium text-green-900">Keine Schuelerklassifikation</div>
<div className="text-sm text-green-700">Keine Kategorisierung oder Profiling</div>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-green-50 rounded-lg">
<span className="text-xl"></span>
<div>
<div className="font-medium text-green-900">Transparente Kennzeichnung</div>
<div className="text-sm text-green-700">KI-Inhalte sind klar markiert</div>
</div>
</div>
</div>
</div>
{/* Management Statement */}
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 text-white">
<div className="flex items-start gap-4">
<span className="text-3xl">💬</span>
<div>
<h3 className="font-semibold text-lg">Management-Statement (Pitch-faehig)</h3>
<blockquote className="mt-3 text-slate-300 italic border-l-4 border-amber-500 pl-4">
&ldquo;Breakpilot ist ein KI-Assistenzsystem mit begrenztem Risiko gemaess EU-AI-Act.
Die KI trifft keine Entscheidungen, sondern unterstuetzt Lehrkraefte transparent und nachvollziehbar.
Alle paedagogischen und rechtlichen Entscheidungen verbleiben beim Menschen.&rdquo;
</blockquote>
</div>
</div>
</div>
</div>
)}
{/* Modules Tab */}
{activeTab === 'modules' && (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Modul</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Risikostufe</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Human-in-Loop</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">AI-Act Artikel</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{MODULE_ASSESSMENTS.map((module) => {
const riskInfo = getRiskLevelInfo(module.riskLevel)
const isExpanded = expandedModule === module.id
return (
<>
<tr key={module.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<div className="font-medium text-slate-800">{module.name}</div>
<div className="text-sm text-slate-500">{module.description}</div>
</td>
<td className="px-4 py-3">
<span className={`px-3 py-1 rounded text-xs font-medium ${riskInfo.color}`}>
{riskInfo.label}
</span>
</td>
<td className="px-4 py-3">
{module.humanInLoop ? (
<span className="text-green-600 font-medium"> Ja</span>
) : (
<span className="text-red-600 font-medium"> Nein</span>
)}
</td>
<td className="px-4 py-3 text-sm text-slate-600">{module.aiActArticle}</td>
<td className="px-4 py-3">
<button
onClick={() => setExpandedModule(isExpanded ? null : module.id)}
className="text-purple-600 hover:text-purple-800 text-sm"
>
{isExpanded ? 'Weniger' : 'Details'}
</button>
</td>
</tr>
{isExpanded && (
<tr key={`${module.id}-details`}>
<td colSpan={5} className="px-4 py-4 bg-slate-50">
<div className="space-y-3">
<div>
<div className="text-xs font-medium text-slate-500 uppercase mb-1">Begruendung</div>
<div className="text-sm text-slate-700">{module.justification}</div>
</div>
<div>
<div className="text-xs font-medium text-slate-500 uppercase mb-1">
Transparenzmassnahmen
</div>
<ul className="text-sm text-slate-700 list-disc list-inside">
{module.transparencyMeasures.map((measure, i) => (
<li key={i}>{measure}</li>
))}
</ul>
</div>
</div>
</td>
</tr>
)}
</>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* Warnings Tab */}
{activeTab === 'warnings' && (
<div className="space-y-6">
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<span className="text-3xl">🚫</span>
<div>
<h3 className="font-semibold text-red-900">
Warnlinien: Was wir NIEMALS bauen duerfen
</h3>
<p className="text-red-700 mt-1">
Diese Features wuerden Breakpilot in die High-Risk oder Unzulaessig-Kategorie verschieben.
Sie sind explizit von der Roadmap ausgeschlossen.
</p>
</div>
</div>
</div>
<div className="grid gap-4">
{WARNING_LINES.map((warning) => {
const statusInfo = getStatusInfo(warning.currentStatus)
const riskInfo = getRiskLevelInfo(warning.wouldTrigger)
return (
<div
key={warning.id}
className={`bg-white rounded-xl border-2 ${
warning.currentStatus === 'safe'
? 'border-green-200'
: warning.currentStatus === 'approaching'
? 'border-amber-300'
: 'border-red-400'
} p-4`}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center text-lg ${statusInfo.color}`}
>
{statusInfo.icon}
</div>
<div>
<h4 className="font-semibold text-slate-900">{warning.title}</h4>
<p className="text-sm text-slate-600 mt-1">{warning.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Wuerde ausloesen:</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${riskInfo.color}`}>
{riskInfo.label}
</span>
</div>
</div>
<div className="mt-3 pl-13 ml-13">
<div className="flex items-center gap-2 text-sm">
<span className="text-slate-500">Empfehlung:</span>
<span className="text-slate-700">{warning.recommendation}</span>
</div>
</div>
</div>
)
})}
</div>
{/* Safe Zone Indicator */}
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-full bg-green-200 flex items-center justify-center">
<span className="text-3xl"></span>
</div>
<div>
<h3 className="font-semibold text-green-900">
Alle Warnlinien eingehalten: {stats.warningsSafe}/{stats.warningsTotal}
</h3>
<p className="text-green-700 mt-1">
Breakpilot befindet sich sicher im Limited/Minimal Risk Bereich des EU-AI-Act.
</p>
</div>
</div>
</div>
</div>
)}
{/* Documentation Tab */}
{activeTab === 'documentation' && (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-slate-900">Klassifizierungs-Memo exportieren</h3>
<p className="text-slate-600 mt-1">
Generiert ein vollstaendiges Compliance-Dokument zur Vorlage bei Auditoren oder Investoren.
</p>
</div>
<button
onClick={downloadMemo}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Als TXT herunterladen
</button>
</div>
</div>
{/* Preview */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Vorschau</h3>
<pre className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-xs font-mono whitespace-pre-wrap max-h-96 overflow-y-auto">
{generateMemo()}
</pre>
</div>
{/* Human-in-the-Loop Documentation */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Human-in-the-Loop Nachweis</h3>
<div className="space-y-4">
<div className="p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium text-blue-900">Technische Massnahmen</h4>
<ul className="mt-2 text-sm text-blue-800 space-y-1">
<li> Kein Auto-Accept Button fuer KI-Vorschlaege</li>
<li> Mindestens 2 Klicks fuer Uebernahme erforderlich</li>
<li> Alle KI-Outputs sind editierbar</li>
<li> Pflicht-Review vor Freigabe an Schueler/Eltern</li>
<li> Audit-Trail dokumentiert alle menschlichen Eingriffe</li>
</ul>
</div>
<div className="p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium text-blue-900">UI-Kennzeichnungen</h4>
<ul className="mt-2 text-sm text-blue-800 space-y-1">
<li> &ldquo;KI-Vorschlag&rdquo; Label auf allen generierten Inhalten</li>
<li> &ldquo;Endverantwortung liegt bei der Lehrkraft&rdquo; Hinweis</li>
<li> Confidence-Scores wo relevant</li>
<li> Quellenangaben fuer RAG-basierte Inhalte</li>
</ul>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,775 +0,0 @@
'use client'
/**
* Audit Checklist Page - 476+ Requirements Interactive Checklist
*
* Features:
* - Session management (create, start, complete)
* - Paginated checklist with search & filters
* - Sign-off workflow with digital signatures
* - Progress tracking with statistics
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// Types
interface AuditSession {
id: string
name: string
auditor_name: string
auditor_email?: string
auditor_organization?: string
status: 'draft' | 'in_progress' | 'completed' | 'archived'
regulation_ids?: string[]
total_items: number
completed_items: number
compliant_count: number
non_compliant_count: number
completion_percentage: number
created_at: string
started_at?: string
completed_at?: string
}
interface ChecklistItem {
requirement_id: string
regulation_code: string
article: string
paragraph?: string
title: string
description?: string
current_result: string
notes?: string
is_signed: boolean
signed_at?: string
signed_by?: string
evidence_count: number
controls_mapped: number
implementation_status: string
priority: number
}
interface AuditStatistics {
total: number
compliant: number
compliant_with_notes: number
non_compliant: number
not_applicable: number
pending: number
completion_percentage: number
}
// Haupt-/Nebenabweichungen aus ISMS
interface FindingsData {
major_count: number // Hauptabweichungen (blockiert Zertifizierung)
minor_count: number // Nebenabweichungen (erfordert CAPA)
ofi_count: number // Verbesserungspotenziale
total: number
open_majors: number // Offene Hauptabweichungen
open_minors: number // Offene Nebenabweichungen
}
const RESULT_COLORS: Record<string, { bg: string; text: string; label: string }> = {
compliant: { bg: 'bg-green-100', text: 'text-green-700', label: 'Konform' },
compliant_notes: { bg: 'bg-green-50', text: 'text-green-600', label: 'Konform (mit Anm.)' },
non_compliant: { bg: 'bg-red-100', text: 'text-red-700', label: 'Nicht konform' },
not_applicable: { bg: 'bg-slate-100', text: 'text-slate-600', label: 'N/A' },
pending: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Ausstehend' },
}
export default function AuditChecklistPage() {
const [sessions, setSessions] = useState<AuditSession[]>([])
const [selectedSession, setSelectedSession] = useState<AuditSession | null>(null)
const [checklist, setChecklist] = useState<ChecklistItem[]>([])
const [statistics, setStatistics] = useState<AuditStatistics | null>(null)
const [loading, setLoading] = useState(true)
const [checklistLoading, setChecklistLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [findings, setFindings] = useState<FindingsData | null>(null)
// Filters
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [regulationFilter, setRegulationFilter] = useState<string>('')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false)
const [showSignOffModal, setShowSignOffModal] = useState(false)
const [selectedItem, setSelectedItem] = useState<ChecklistItem | null>(null)
// New session form
const [newSession, setNewSession] = useState({
name: '',
auditor_name: '',
auditor_email: '',
auditor_organization: '',
regulation_codes: [] as string[],
})
useEffect(() => {
loadSessions()
loadFindings()
}, [])
const loadFindings = async () => {
try {
const res = await fetch('/api/admin/compliance/isms/findings/summary')
if (res.ok) {
const data = await res.json()
setFindings(data)
}
} catch (err) {
console.error('Failed to load findings:', err)
}
}
useEffect(() => {
if (selectedSession) {
loadChecklist()
}
}, [selectedSession, page, statusFilter, regulationFilter, search])
const loadSessions = async () => {
setLoading(true)
try {
const res = await fetch('/api/admin/audit/sessions')
if (res.ok) {
const data = await res.json()
setSessions(data)
}
} catch (err) {
console.error('Failed to load sessions:', err)
setError('Sessions konnten nicht geladen werden')
} finally {
setLoading(false)
}
}
const loadChecklist = async () => {
if (!selectedSession) return
setChecklistLoading(true)
try {
const params = new URLSearchParams({
page: page.toString(),
page_size: '50',
})
if (statusFilter) params.set('status_filter', statusFilter)
if (regulationFilter) params.set('regulation_filter', regulationFilter)
if (search) params.set('search', search)
const res = await fetch(`/api/admin/compliance/audit/checklist/${selectedSession.id}?${params}`)
if (res.ok) {
const data = await res.json()
setChecklist(data.items || [])
setStatistics(data.statistics)
setTotalPages(data.pagination?.total_pages || 1)
}
} catch (err) {
console.error('Failed to load checklist:', err)
} finally {
setChecklistLoading(false)
}
}
const createSession = async () => {
try {
const res = await fetch('/api/admin/audit/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSession),
})
if (res.ok) {
const session = await res.json()
setSessions([session, ...sessions])
setSelectedSession(session)
setShowCreateModal(false)
setNewSession({
name: '',
auditor_name: '',
auditor_email: '',
auditor_organization: '',
regulation_codes: [],
})
}
} catch (err) {
console.error('Failed to create session:', err)
}
}
const startSession = async (sessionId: string) => {
try {
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/start`, {
method: 'PUT',
})
if (res.ok) {
loadSessions()
if (selectedSession?.id === sessionId) {
setSelectedSession({ ...selectedSession, status: 'in_progress' })
}
}
} catch (err) {
console.error('Failed to start session:', err)
}
}
const completeSession = async (sessionId: string) => {
try {
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/complete`, {
method: 'PUT',
})
if (res.ok) {
loadSessions()
if (selectedSession?.id === sessionId) {
setSelectedSession({ ...selectedSession, status: 'completed' })
}
}
} catch (err) {
console.error('Failed to complete session:', err)
}
}
const signOffItem = async (result: string, notes: string, sign: boolean) => {
if (!selectedSession || !selectedItem) return
try {
const res = await fetch(
`/api/admin/compliance/audit/checklist/${selectedSession.id}/items/${selectedItem.requirement_id}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ result, notes, sign }),
}
)
if (res.ok) {
loadChecklist()
loadSessions()
setShowSignOffModal(false)
setSelectedItem(null)
}
} catch (err) {
console.error('Failed to sign off:', err)
}
}
const downloadPdf = async (sessionId: string) => {
window.open(`/api/admin/audit/sessions/${sessionId}/pdf`, '_blank')
}
return (
<div className="space-y-6">
<PagePurpose
title="Audit Checkliste"
purpose="Interaktive Checkliste mit 476+ Compliance-Anforderungen aus DSGVO, AI Act, CRA und BSI TR-03161. Erstellen Sie Audit-Sessions, bewerten Sie Anforderungen und generieren Sie Audit-Reports mit digitalen Signaturen."
audience={['Auditor', 'DSB', 'Compliance Officer']}
gdprArticles={['Art. 5 (Rechenschaftspflicht)', 'Art. 30 (Verzeichnis)', 'Art. 32 (Sicherheit)']}
architecture={{
services: ['Python Backend', 'PostgreSQL'],
databases: ['compliance_audit_sessions', 'compliance_audit_signoffs', 'compliance_requirements'],
}}
relatedPages={[
{ name: 'Compliance Hub', href: '/compliance/hub', description: 'Uebersicht & Dashboard' },
{ name: 'Audit Report', href: '/compliance/audit-report', description: 'PDF-Reports generieren' },
{ name: 'Controls', href: '/compliance/controls', description: 'Technische Massnahmen' },
]}
/>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-700">{error}</p>
</div>
)}
{/* Haupt-/Nebenabweichungen Uebersicht */}
{findings && (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900">Audit Findings (ISMS)</h2>
<span className={`px-3 py-1 text-sm rounded-full ${
findings.open_majors > 0
? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'
}`}>
{findings.open_majors > 0 ? 'Zertifizierung blockiert' : 'Zertifizierungsfaehig'}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="text-center p-4 bg-red-50 rounded-lg border border-red-200">
<p className="text-3xl font-bold text-red-700">{findings.major_count}</p>
<p className="text-sm text-red-600 font-medium">Hauptabweichungen</p>
<p className="text-xs text-red-500 mt-1">(MAJOR)</p>
{findings.open_majors > 0 && (
<p className="text-xs text-red-700 mt-2 font-medium">
{findings.open_majors} offen
</p>
)}
</div>
<div className="text-center p-4 bg-orange-50 rounded-lg border border-orange-200">
<p className="text-3xl font-bold text-orange-700">{findings.minor_count}</p>
<p className="text-sm text-orange-600 font-medium">Nebenabweichungen</p>
<p className="text-xs text-orange-500 mt-1">(MINOR)</p>
{findings.open_minors > 0 && (
<p className="text-xs text-orange-700 mt-2 font-medium">
{findings.open_minors} offen
</p>
)}
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
<p className="text-3xl font-bold text-blue-700">{findings.ofi_count}</p>
<p className="text-sm text-blue-600 font-medium">Verbesserungen</p>
<p className="text-xs text-blue-500 mt-1">(OFI)</p>
</div>
<div className="text-center p-4 bg-slate-50 rounded-lg border border-slate-200">
<p className="text-3xl font-bold text-slate-700">{findings.total}</p>
<p className="text-sm text-slate-600 font-medium">Gesamt Findings</p>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg border border-purple-200">
<div className="flex flex-col items-center">
<svg className={`w-8 h-8 ${findings.open_majors === 0 ? 'text-green-500' : 'text-red-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{findings.open_majors === 0 ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
)}
</svg>
<p className="text-sm text-purple-600 font-medium mt-2">Zertifizierung</p>
<p className={`text-xs mt-1 font-medium ${findings.open_majors === 0 ? 'text-green-600' : 'text-red-600'}`}>
{findings.open_majors === 0 ? 'Moeglich' : 'Blockiert'}
</p>
</div>
</div>
</div>
<div className="mt-4 p-3 bg-slate-50 rounded-lg">
<p className="text-xs text-slate-600">
<strong>Hauptabweichung (MAJOR):</strong> Signifikante Abweichung von Anforderungen - blockiert Zertifizierung bis zur Behebung.{' '}
<strong>Nebenabweichung (MINOR):</strong> Kleinere Abweichung - erfordert CAPA (Corrective Action) innerhalb 90 Tagen.
</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sessions Sidebar */}
<div className="lg:col-span-1 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900">Audit Sessions</h2>
<button
onClick={() => setShowCreateModal(true)}
className="p-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : sessions.length === 0 ? (
<div className="text-center py-8 text-slate-500">
<p>Keine Sessions vorhanden</p>
<button
onClick={() => setShowCreateModal(true)}
className="mt-2 text-purple-600 hover:text-purple-700"
>
Erste Session erstellen
</button>
</div>
) : (
<div className="space-y-2">
{sessions.map((session) => (
<div
key={session.id}
onClick={() => setSelectedSession(session)}
className={`p-4 rounded-lg border cursor-pointer transition-colors ${
selectedSession?.id === session.id
? 'border-purple-500 bg-purple-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className={`px-2 py-0.5 text-xs rounded-full ${
session.status === 'completed' ? 'bg-green-100 text-green-700' :
session.status === 'in_progress' ? 'bg-blue-100 text-blue-700' :
session.status === 'archived' ? 'bg-slate-100 text-slate-700' :
'bg-yellow-100 text-yellow-700'
}`}>
{session.status === 'completed' ? 'Abgeschlossen' :
session.status === 'in_progress' ? 'In Bearbeitung' :
session.status === 'archived' ? 'Archiviert' : 'Entwurf'}
</span>
<span className="text-xs text-slate-500">{session.completion_percentage.toFixed(0)}%</span>
</div>
<h3 className="font-medium text-slate-900 truncate">{session.name}</h3>
<p className="text-sm text-slate-500">{session.auditor_name}</p>
<div className="mt-2 h-1.5 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-500"
style={{ width: `${session.completion_percentage}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
{/* Checklist Content */}
<div className="lg:col-span-3 space-y-4">
{!selectedSession ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 className="text-lg font-medium text-slate-900">Waehlen Sie eine Session</h3>
<p className="text-slate-500 mt-2">Waehlen Sie eine Audit-Session aus der Liste oder erstellen Sie eine neue.</p>
</div>
) : (
<>
{/* Session Header */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold text-slate-900">{selectedSession.name}</h2>
<p className="text-slate-500">{selectedSession.auditor_name} {selectedSession.auditor_organization && `- ${selectedSession.auditor_organization}`}</p>
</div>
<div className="flex gap-2">
{selectedSession.status === 'draft' && (
<button
onClick={() => startSession(selectedSession.id)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Starten
</button>
)}
{selectedSession.status === 'in_progress' && (
<button
onClick={() => completeSession(selectedSession.id)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Abschliessen
</button>
)}
{selectedSession.status === 'completed' && (
<button
onClick={() => downloadPdf(selectedSession.id)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
PDF Export
</button>
)}
</div>
</div>
{/* Statistics */}
{statistics && (
<div className="grid grid-cols-6 gap-4">
<div className="text-center p-3 bg-slate-50 rounded-lg">
<p className="text-2xl font-bold text-slate-900">{statistics.total}</p>
<p className="text-xs text-slate-500">Gesamt</p>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<p className="text-2xl font-bold text-green-700">{statistics.compliant + statistics.compliant_with_notes}</p>
<p className="text-xs text-green-600">Konform</p>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<p className="text-2xl font-bold text-red-700">{statistics.non_compliant}</p>
<p className="text-xs text-red-600">Nicht konform</p>
</div>
<div className="text-center p-3 bg-slate-50 rounded-lg">
<p className="text-2xl font-bold text-slate-700">{statistics.not_applicable}</p>
<p className="text-xs text-slate-500">N/A</p>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<p className="text-2xl font-bold text-yellow-700">{statistics.pending}</p>
<p className="text-xs text-yellow-600">Ausstehend</p>
</div>
<div className="text-center p-3 bg-purple-50 rounded-lg">
<p className="text-2xl font-bold text-purple-700">{statistics.completion_percentage.toFixed(0)}%</p>
<p className="text-xs text-purple-600">Fortschritt</p>
</div>
</div>
)}
</div>
{/* Filters */}
<div className="flex gap-4">
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
placeholder="Suche..."
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Alle Status</option>
<option value="pending">Ausstehend</option>
<option value="compliant">Konform</option>
<option value="non_compliant">Nicht konform</option>
<option value="not_applicable">N/A</option>
</select>
</div>
{/* Checklist Table */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
{checklistLoading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : checklist.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Eintraege gefunden
</div>
) : (
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Regulation</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Artikel</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Controls</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{checklist.map((item) => {
const resultConfig = RESULT_COLORS[item.current_result] || RESULT_COLORS.pending
return (
<tr key={item.requirement_id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono text-sm text-purple-600">{item.regulation_code}</span>
</td>
<td className="px-4 py-3">
<span className="font-medium">{item.article}</span>
{item.paragraph && <span className="text-slate-500 text-sm"> {item.paragraph}</span>}
</td>
<td className="px-4 py-3">
<p className="text-sm text-slate-900 line-clamp-2">{item.title}</p>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
item.controls_mapped > 0 ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'
}`}>
{item.controls_mapped}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${resultConfig.bg} ${resultConfig.text}`}>
{resultConfig.label}
</span>
{item.is_signed && (
<svg className="w-4 h-4 text-green-600 inline-block ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => { setSelectedItem(item); setShowSignOffModal(true) }}
className="px-3 py-1 text-sm bg-purple-100 text-purple-700 rounded hover:bg-purple-200"
disabled={selectedSession.status !== 'in_progress' && selectedSession.status !== 'draft'}
>
Bewerten
</button>
</td>
</tr>
)
})}
</tbody>
</table>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="px-4 py-3 border-t flex items-center justify-between">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1 border rounded disabled:opacity-50"
>
Zurueck
</button>
<span className="text-sm text-slate-500">
Seite {page} von {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1 border rounded disabled:opacity-50"
>
Weiter
</button>
</div>
)}
</div>
</>
)}
</div>
</div>
{/* Create Session Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md">
<h3 className="text-lg font-semibold mb-4">Neue Audit Session</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
value={newSession.name}
onChange={(e) => setNewSession({ ...newSession, name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
placeholder="z.B. Q1 2026 DSGVO Audit"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Auditor Name</label>
<input
type="text"
value={newSession.auditor_name}
onChange={(e) => setNewSession({ ...newSession, auditor_name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
placeholder="Dr. Max Mustermann"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Organisation (optional)</label>
<input
type="text"
value={newSession.auditor_organization}
onChange={(e) => setNewSession({ ...newSession, auditor_organization: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
placeholder="TÜV Rheinland"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 border rounded-lg"
>
Abbrechen
</button>
<button
onClick={createSession}
disabled={!newSession.name || !newSession.auditor_name}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
Erstellen
</button>
</div>
</div>
</div>
)}
{/* Sign Off Modal */}
{showSignOffModal && selectedItem && (
<SignOffModal
item={selectedItem}
onClose={() => { setShowSignOffModal(false); setSelectedItem(null) }}
onSignOff={signOffItem}
/>
)}
</div>
)
}
// Sign Off Modal Component
function SignOffModal({
item,
onClose,
onSignOff,
}: {
item: ChecklistItem
onClose: () => void
onSignOff: (result: string, notes: string, sign: boolean) => void
}) {
const [result, setResult] = useState(item.current_result === 'pending' ? '' : item.current_result)
const [notes, setNotes] = useState(item.notes || '')
const [sign, setSign] = useState(false)
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg">
<h3 className="text-lg font-semibold mb-2">Anforderung bewerten</h3>
<p className="text-sm text-slate-500 mb-4">
{item.regulation_code} {item.article}: {item.title}
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Bewertung</label>
<div className="grid grid-cols-2 gap-2">
{[
{ value: 'compliant', label: 'Konform', color: 'green' },
{ value: 'compliant_notes', label: 'Konform (mit Anm.)', color: 'green' },
{ value: 'non_compliant', label: 'Nicht konform', color: 'red' },
{ value: 'not_applicable', label: 'Nicht anwendbar', color: 'slate' },
].map((opt) => (
<button
key={opt.value}
onClick={() => setResult(opt.value)}
className={`p-3 rounded-lg border-2 text-left transition-colors ${
result === opt.value
? opt.color === 'green' ? 'border-green-500 bg-green-50' :
opt.color === 'red' ? 'border-red-500 bg-red-50' :
'border-slate-500 bg-slate-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<span className="font-medium">{opt.label}</span>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Anmerkungen</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full px-3 py-2 border rounded-lg"
rows={3}
placeholder="Optionale Anmerkungen..."
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="sign"
checked={sign}
onChange={(e) => setSign(e.target.checked)}
className="w-4 h-4 rounded"
/>
<label htmlFor="sign" className="text-sm text-slate-700">
Digitale Signatur erstellen (SHA-256)
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button onClick={onClose} className="px-4 py-2 border rounded-lg">
Abbrechen
</button>
<button
onClick={() => onSignOff(result, notes, sign)}
disabled={!result}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
Speichern
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,705 +0,0 @@
'use client'
/**
* Audit Report Management Page
*
* Create and manage GDPR audit sessions with PDF report generation.
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface AuditSession {
id: string
name: string
description?: string
auditor_name: string
auditor_email?: string
auditor_organization?: string
status: 'draft' | 'in_progress' | 'completed' | 'archived'
regulation_ids?: string[]
total_items: number
completed_items: number
compliant_count: number
non_compliant_count: number
completion_percentage: number
created_at: string
started_at?: string
completed_at?: string
}
// Available regulations for filtering
const REGULATIONS = [
{ code: 'GDPR', name: 'DSGVO / GDPR', description: 'EU-Datenschutzgrundverordnung' },
{ code: 'BDSG', name: 'BDSG', description: 'Bundesdatenschutzgesetz' },
{ code: 'TTDSG', name: 'TTDSG', description: 'Telekommunikation-Telemedien-Datenschutz' },
]
export default function AuditReportPage() {
const [sessions, setSessions] = useState<AuditSession[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'sessions' | 'new' | 'export'>('sessions')
// New session form
const [newSession, setNewSession] = useState({
name: '',
description: '',
auditor_name: '',
auditor_email: '',
auditor_organization: '',
regulation_codes: [] as string[],
})
const [creating, setCreating] = useState(false)
// PDF generation
const [generatingPdf, setGeneratingPdf] = useState<string | null>(null)
const [pdfLanguage, setPdfLanguage] = useState<'de' | 'en'>('de')
// Status filter
const [statusFilter, setStatusFilter] = useState<string>('all')
useEffect(() => {
fetchSessions()
}, [statusFilter])
const fetchSessions = async () => {
try {
setLoading(true)
const params = statusFilter !== 'all' ? `?status=${statusFilter}` : ''
const res = await fetch(`/api/admin/audit/sessions${params}`)
if (!res.ok) {
throw new Error('Fehler beim Laden der Audit-Sessions')
}
const data = await res.json()
setSessions(data.sessions || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const createSession = async () => {
if (!newSession.name || !newSession.auditor_name) {
setError('Name und Auditor-Name sind Pflichtfelder')
return
}
try {
setCreating(true)
const res = await fetch('/api/admin/audit/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSession),
})
if (!res.ok) {
throw new Error('Fehler beim Erstellen der Session')
}
// Reset form and refresh
setNewSession({
name: '',
description: '',
auditor_name: '',
auditor_email: '',
auditor_organization: '',
regulation_codes: [],
})
setActiveTab('sessions')
fetchSessions()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setCreating(false)
}
}
const startSession = async (sessionId: string) => {
try {
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/start`, {
method: 'PUT',
})
if (!res.ok) {
throw new Error('Fehler beim Starten der Session')
}
fetchSessions()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const completeSession = async (sessionId: string) => {
try {
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/complete`, {
method: 'PUT',
})
if (!res.ok) {
throw new Error('Fehler beim Abschliessen der Session')
}
fetchSessions()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const deleteSession = async (sessionId: string) => {
if (!confirm('Session wirklich loeschen?')) return
try {
const res = await fetch(`/api/admin/audit/sessions/${sessionId}`, {
method: 'DELETE',
})
if (!res.ok) {
throw new Error('Fehler beim Loeschen der Session')
}
fetchSessions()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const downloadPdf = async (sessionId: string) => {
try {
setGeneratingPdf(sessionId)
const res = await fetch(
`/api/admin/audit/sessions/${sessionId}/pdf?language=${pdfLanguage}`
)
if (!res.ok) {
throw new Error('Fehler bei der PDF-Generierung')
}
// Download the PDF
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-report-${sessionId}.pdf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setGeneratingPdf(null)
}
}
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
draft: 'bg-slate-100 text-slate-700',
in_progress: 'bg-blue-100 text-blue-700',
completed: 'bg-green-100 text-green-700',
archived: 'bg-purple-100 text-purple-700',
}
const labels: Record<string, string> = {
draft: 'Entwurf',
in_progress: 'In Bearbeitung',
completed: 'Abgeschlossen',
archived: 'Archiviert',
}
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[status] || ''}`}>
{labels[status] || status}
</span>
)
}
const getComplianceColor = (percentage: number) => {
if (percentage >= 80) return 'text-green-600'
if (percentage >= 50) return 'text-yellow-600'
return 'text-red-600'
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Audit Report"
purpose="Erstellen und verwalten Sie DSGVO-Audit-Sessions. Generieren Sie PDF-Berichte fuer Auditoren und Aufsichtsbehoerden mit vollstaendiger Checkliste, Sign-Off-Status und digitalen Signaturen."
audience={['DSB', 'Auditor', 'Compliance Officer']}
gdprArticles={[
'Art. 5 (Rechenschaftspflicht)',
'Art. 24 (Verantwortung des Verantwortlichen)',
'Art. 39 (Aufgaben des DSB)',
]}
architecture={{
services: ['backend (Python)', 'ReportLab PDF'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'DSMS', href: '/compliance/dsms', description: 'Uebersicht Datenschutz-Management' },
{ name: 'Einwilligungen', href: '/compliance/einwilligungen', description: 'Consent-Tracking' },
{ name: 'VVT', href: '/compliance/vvt', description: 'Verarbeitungsverzeichnis' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Tabs */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTab('sessions')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeTab === 'sessions'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Audit-Sessions
</button>
<button
onClick={() => setActiveTab('new')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeTab === 'new'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
+ Neues Audit
</button>
<button
onClick={() => setActiveTab('export')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeTab === 'export'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Export-Optionen
</button>
</div>
{/* Sessions Tab */}
{activeTab === 'sessions' && (
<div>
{/* Filter */}
<div className="flex items-center gap-4 mb-4">
<label className="text-sm text-slate-600">Status:</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
>
<option value="all">Alle</option>
<option value="draft">Entwurf</option>
<option value="in_progress">In Bearbeitung</option>
<option value="completed">Abgeschlossen</option>
<option value="archived">Archiviert</option>
</select>
<button
onClick={fetchSessions}
className="px-3 py-2 text-sm text-purple-600 hover:text-purple-700"
>
Aktualisieren
</button>
</div>
{/* Sessions List */}
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Audit-Sessions...</div>
) : sessions.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Audit-Sessions vorhanden</h3>
<p className="text-sm text-slate-500 mb-4">
Erstellen Sie ein neues Audit, um mit der DSGVO-Pruefung zu beginnen.
</p>
<button
onClick={() => setActiveTab('new')}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Neues Audit erstellen
</button>
</div>
) : (
<div className="space-y-4">
{sessions.map((session) => (
<div
key={session.id}
className="bg-white rounded-xl border border-slate-200 p-6"
>
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-3">
<h3 className="font-semibold text-slate-900">{session.name}</h3>
{getStatusBadge(session.status)}
</div>
{session.description && (
<p className="text-sm text-slate-500 mt-1">{session.description}</p>
)}
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span>Auditor: {session.auditor_name}</span>
{session.auditor_organization && (
<span>| {session.auditor_organization}</span>
)}
<span>| Erstellt: {new Date(session.created_at).toLocaleDateString('de-DE')}</span>
</div>
</div>
<div className="text-right">
<div className={`text-2xl font-bold ${getComplianceColor(session.completion_percentage)}`}>
{session.completion_percentage}%
</div>
<div className="text-xs text-slate-500">
{session.completed_items} / {session.total_items} Punkte
</div>
</div>
</div>
{/* Progress Bar */}
<div className="h-2 bg-slate-100 rounded-full overflow-hidden mb-4">
<div
className={`h-full transition-all ${
session.completion_percentage >= 80
? 'bg-green-500'
: session.completion_percentage >= 50
? 'bg-yellow-500'
: 'bg-red-500'
}`}
style={{ width: `${session.completion_percentage}%` }}
/>
</div>
{/* Statistics */}
<div className="grid grid-cols-3 gap-4 mb-4 text-sm">
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="font-semibold text-green-700">{session.compliant_count}</div>
<div className="text-xs text-green-600">Konform</div>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="font-semibold text-red-700">{session.non_compliant_count}</div>
<div className="text-xs text-red-600">Nicht Konform</div>
</div>
<div className="text-center p-3 bg-slate-50 rounded-lg">
<div className="font-semibold text-slate-700">
{session.total_items - session.completed_items}
</div>
<div className="text-xs text-slate-600">Ausstehend</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-4 border-t border-slate-100">
{session.status === 'draft' && (
<button
onClick={() => startSession(session.id)}
className="px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700"
>
Audit starten
</button>
)}
{session.status === 'in_progress' && (
<button
onClick={() => completeSession(session.id)}
className="px-3 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700"
>
Abschliessen
</button>
)}
{(session.status === 'completed' || session.status === 'in_progress') && (
<button
onClick={() => downloadPdf(session.id)}
disabled={generatingPdf === session.id}
className="px-3 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-2"
>
{generatingPdf === session.id ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Generiere PDF...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
PDF-Report
</>
)}
</button>
)}
{(session.status === 'draft' || session.status === 'archived') && (
<button
onClick={() => deleteSession(session.id)}
className="px-3 py-2 text-red-600 text-sm hover:text-red-700"
>
Loeschen
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* New Session Tab */}
{activeTab === 'new' && (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Neues Audit erstellen</h2>
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Audit-Name *
</label>
<input
type="text"
value={newSession.name}
onChange={(e) => setNewSession({ ...newSession, name: e.target.value })}
placeholder="z.B. DSGVO Jahresaudit 2026"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<textarea
value={newSession.description}
onChange={(e) => setNewSession({ ...newSession, description: e.target.value })}
rows={3}
placeholder="Optionale Beschreibung des Audit-Umfangs"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
{/* Auditor Info */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Auditor Name *
</label>
<input
type="text"
value={newSession.auditor_name}
onChange={(e) => setNewSession({ ...newSession, auditor_name: e.target.value })}
placeholder="Name des Auditors"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
E-Mail
</label>
<input
type="email"
value={newSession.auditor_email}
onChange={(e) => setNewSession({ ...newSession, auditor_email: e.target.value })}
placeholder="auditor@example.com"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Organisation
</label>
<input
type="text"
value={newSession.auditor_organization}
onChange={(e) => setNewSession({ ...newSession, auditor_organization: e.target.value })}
placeholder="z.B. TUeV, Aufsichtsbehoerde"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
{/* Regulations */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Zu pruefende Regelwerke
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{REGULATIONS.map((reg) => (
<label
key={reg.code}
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${
newSession.regulation_codes.includes(reg.code)
? 'border-purple-500 bg-purple-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<input
type="checkbox"
checked={newSession.regulation_codes.includes(reg.code)}
onChange={(e) => {
if (e.target.checked) {
setNewSession({
...newSession,
regulation_codes: [...newSession.regulation_codes, reg.code],
})
} else {
setNewSession({
...newSession,
regulation_codes: newSession.regulation_codes.filter((c) => c !== reg.code),
})
}
}}
className="w-4 h-4 text-purple-600"
/>
<div>
<div className="font-medium text-slate-800">{reg.name}</div>
<div className="text-xs text-slate-500">{reg.description}</div>
</div>
</label>
))}
</div>
</div>
{/* Submit */}
<div className="pt-4 border-t border-slate-100">
<button
onClick={createSession}
disabled={creating}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-2"
>
{creating ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Erstelle...
</>
) : (
'Audit-Session erstellen'
)}
</button>
</div>
</div>
</div>
)}
{/* Export Options Tab */}
{activeTab === 'export' && (
<div className="space-y-6">
{/* PDF Language Settings */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">PDF-Export Einstellungen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Sprache</label>
<div className="flex gap-3">
<label className={`flex items-center gap-2 px-4 py-2 border rounded-lg cursor-pointer ${pdfLanguage === 'de' ? 'border-purple-500 bg-purple-50' : 'border-slate-200'}`}>
<input
type="radio"
checked={pdfLanguage === 'de'}
onChange={() => setPdfLanguage('de')}
className="w-4 h-4 text-purple-600"
/>
<span>Deutsch</span>
</label>
<label className={`flex items-center gap-2 px-4 py-2 border rounded-lg cursor-pointer ${pdfLanguage === 'en' ? 'border-purple-500 bg-purple-50' : 'border-slate-200'}`}>
<input
type="radio"
checked={pdfLanguage === 'en'}
onChange={() => setPdfLanguage('en')}
className="w-4 h-4 text-purple-600"
/>
<span>English</span>
</label>
</div>
</div>
</div>
</div>
{/* Export Types Info */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Verfuegbare Export-Formate</h3>
<div className="space-y-4">
<div className="flex items-start gap-4 p-4 bg-slate-50 rounded-lg">
<div className="w-10 h-10 rounded-lg bg-red-100 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<div>
<h4 className="font-medium text-slate-800">PDF Audit Report</h4>
<p className="text-sm text-slate-600 mt-1">
Vollstaendiger Audit-Bericht mit Deckblatt, Executive Summary, Checkliste und digitalen Signaturen.
Ideal fuer Aufsichtsbehoerden und offizielle Dokumentation.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-4 bg-slate-50 rounded-lg">
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</div>
<div>
<h4 className="font-medium text-slate-800">ZIP Export-Paket</h4>
<p className="text-sm text-slate-600 mt-1">
Komplettes Export-Paket mit Regelwerken, Controls, Nachweisen und interaktivem HTML-Index.
Fuer externe Auditoren zur detaillierten Pruefung.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-4 bg-slate-50 rounded-lg">
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<h4 className="font-medium text-slate-800">Compliance Report (JSON)</h4>
<p className="text-sm text-slate-600 mt-1">
Strukturierter Bericht mit Statistiken, Trends und Empfehlungen.
Fuer Integration in andere Systeme und Dashboards.
</p>
</div>
</div>
</div>
</div>
{/* Tip */}
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-purple-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-medium text-purple-800">Tipp</h4>
<p className="text-sm text-purple-700 mt-1">
Der PDF-Report enthaelt SHA-256-Signaturen fuer alle Sign-Offs. Diese koennen zur Integritaetspruefung
verwendet werden und belegen, dass die Bewertungen nicht nachtraeglich veraendert wurden.
</p>
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,648 +0,0 @@
'use client'
/**
* Consent Admin Panel
*
* Admin interface for managing:
* - Documents (AGB, Privacy, etc.)
* - Document Versions
* - Email Templates
* - GDPR Processes (Art. 15-21)
* - Statistics
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// API Proxy URL (avoids CORS issues)
const API_BASE = '/api/admin/consent'
type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats'
interface Document {
id: string
type: string
name: string
description: string
mandatory: boolean
created_at: string
updated_at: string
}
interface Version {
id: string
document_id: string
version: string
language: string
title: string
content: string
status: string
created_at: string
}
export default function ConsentPage() {
const [activeTab, setActiveTab] = useState<Tab>('documents')
const [documents, setDocuments] = useState<Document[]>([])
const [versions, setVersions] = useState<Version[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedDocument, setSelectedDocument] = useState<string>('')
// Auth token (in production, get from auth context)
const [authToken, setAuthToken] = useState<string>('')
useEffect(() => {
// Get token from localStorage
const token = localStorage.getItem('bp_admin_token')
if (token) {
setAuthToken(token)
}
}, [])
useEffect(() => {
if (activeTab === 'documents') {
loadDocuments()
} else if (activeTab === 'versions' && selectedDocument) {
loadVersions(selectedDocument)
}
}, [activeTab, selectedDocument, authToken])
async function loadDocuments() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/documents`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setDocuments(data.documents || [])
} else {
const errorData = await res.json().catch(() => ({}))
setError(errorData.error || 'Fehler beim Laden der Dokumente')
}
} catch (err) {
setError('Verbindungsfehler zum Server')
} finally {
setLoading(false)
}
}
async function loadVersions(docId: string) {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/documents/${docId}/versions`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setVersions(data.versions || [])
} else {
const errorData = await res.json().catch(() => ({}))
setError(errorData.error || 'Fehler beim Laden der Versionen')
}
} catch (err) {
setError('Verbindungsfehler zum Server')
} finally {
setLoading(false)
}
}
const tabs: { id: Tab; label: string }[] = [
{ id: 'documents', label: 'Dokumente' },
{ id: 'versions', label: 'Versionen' },
{ id: 'emails', label: 'E-Mail Vorlagen' },
{ id: 'gdpr', label: 'DSGVO Prozesse' },
{ id: 'stats', label: 'Statistiken' },
]
// 16 Lifecycle Email Templates
const emailTemplates = [
// Onboarding
{ name: 'Willkommens-E-Mail', key: 'welcome', category: 'onboarding', description: 'Wird bei Registrierung versendet' },
{ name: 'E-Mail Bestaetigung', key: 'email_verification', category: 'onboarding', description: 'Bestaetigungslink fuer E-Mail-Adresse' },
{ name: 'Konto aktiviert', key: 'account_activated', category: 'onboarding', description: 'Bestaetigung der Kontoaktivierung' },
// Security
{ name: 'Passwort zuruecksetzen', key: 'password_reset', category: 'security', description: 'Link zum Zuruecksetzen des Passworts' },
{ name: 'Passwort geaendert', key: 'password_changed', category: 'security', description: 'Bestaetigung der Passwortaenderung' },
{ name: 'Neue Anmeldung', key: 'new_login', category: 'security', description: 'Benachrichtigung ueber Anmeldung von neuem Geraet' },
{ name: '2FA aktiviert', key: '2fa_enabled', category: 'security', description: 'Bestaetigung der 2FA-Aktivierung' },
// Consent & Legal
{ name: 'Neue Dokumentversion', key: 'new_document_version', category: 'consent', description: 'Info ueber neue Dokumentversion zur Zustimmung' },
{ name: 'Zustimmung erteilt', key: 'consent_given', category: 'consent', description: 'Bestaetigung der erteilten Zustimmung' },
{ name: 'Zustimmung widerrufen', key: 'consent_withdrawn', category: 'consent', description: 'Bestaetigung des Widerrufs' },
// Data Subject Rights (GDPR)
{ name: 'DSR Anfrage erhalten', key: 'dsr_received', category: 'gdpr', description: 'Bestaetigung des Eingangs einer DSGVO-Anfrage' },
{ name: 'Datenexport bereit', key: 'export_ready', category: 'gdpr', description: 'Benachrichtigung ueber fertigen Datenexport' },
{ name: 'Daten geloescht', key: 'data_deleted', category: 'gdpr', description: 'Bestaetigung der Datenloeschung' },
{ name: 'Daten berichtigt', key: 'data_rectified', category: 'gdpr', description: 'Bestaetigung der Datenberichtigung' },
// Account Lifecycle
{ name: 'Konto deaktiviert', key: 'account_deactivated', category: 'lifecycle', description: 'Konto wurde deaktiviert' },
{ name: 'Konto geloescht', key: 'account_deleted', category: 'lifecycle', description: 'Bestaetigung der Kontoloeschung' },
]
// GDPR Article 15-21 Processes
const gdprProcesses = [
{
article: '15',
title: 'Auskunftsrecht',
description: 'Recht auf Bestaetigung und Auskunft ueber verarbeitete personenbezogene Daten',
actions: ['Datenexport generieren', 'Verarbeitungszwecke auflisten', 'Empfaenger auflisten'],
sla: '30 Tage',
status: 'active'
},
{
article: '16',
title: 'Recht auf Berichtigung',
description: 'Recht auf Berichtigung unrichtiger personenbezogener Daten',
actions: ['Daten bearbeiten', 'Aenderungshistorie fuehren', 'Benachrichtigung senden'],
sla: '30 Tage',
status: 'active'
},
{
article: '17',
title: 'Recht auf Loeschung ("Vergessenwerden")',
description: 'Recht auf Loeschung personenbezogener Daten unter bestimmten Voraussetzungen',
actions: ['Loeschantrag pruefen', 'Daten loeschen', 'Aufbewahrungsfristen pruefen', 'Loeschbestaetigung senden'],
sla: '30 Tage',
status: 'active'
},
{
article: '18',
title: 'Recht auf Einschraenkung der Verarbeitung',
description: 'Recht auf Markierung von Daten zur eingeschraenkten Verarbeitung',
actions: ['Daten markieren', 'Verarbeitung einschraenken', 'Benachrichtigung bei Aufhebung'],
sla: '30 Tage',
status: 'active'
},
{
article: '19',
title: 'Mitteilungspflicht',
description: 'Pflicht zur Mitteilung von Berichtigung, Loeschung oder Einschraenkung an Empfaenger',
actions: ['Empfaenger ermitteln', 'Mitteilungen versenden', 'Protokollierung'],
sla: 'Unverzueglich',
status: 'active'
},
{
article: '20',
title: 'Recht auf Datenuebertragbarkeit',
description: 'Recht auf Erhalt der Daten in strukturiertem, maschinenlesbarem Format',
actions: ['JSON/CSV Export', 'API-Schnittstelle', 'Direkte Uebertragung'],
sla: '30 Tage',
status: 'active'
},
{
article: '21',
title: 'Widerspruchsrecht',
description: 'Recht auf Widerspruch gegen Verarbeitung aus berechtigtem Interesse oder Direktwerbung',
actions: ['Widerspruch erfassen', 'Verarbeitung stoppen', 'Marketing-Opt-out'],
sla: 'Unverzueglich',
status: 'active'
},
]
const emailCategories = [
{ key: 'onboarding', label: 'Onboarding', color: 'bg-blue-100 text-blue-700' },
{ key: 'security', label: 'Sicherheit', color: 'bg-red-100 text-red-700' },
{ key: 'consent', label: 'Zustimmung', color: 'bg-green-100 text-green-700' },
{ key: 'gdpr', label: 'DSGVO', color: 'bg-purple-100 text-purple-700' },
{ key: 'lifecycle', label: 'Lifecycle', color: 'bg-orange-100 text-orange-700' },
]
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Consent Verwaltung"
purpose="Verwalten Sie rechtliche Dokumente (AGB, Datenschutz, Cookie-Richtlinien) und deren Versionen. Jede Einwilligung eines Benutzers basiert auf diesen Dokumenten und muss nachvollziehbar sein."
audience={['DSB', 'Entwickler', 'Compliance Officer']}
gdprArticles={['Art. 7 (Einwilligung)', 'Art. 13/14 (Informationspflichten)']}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'DSR-Verwaltung', href: '/compliance/dsr', description: 'Datenschutzanfragen bearbeiten' },
{ name: 'DSGVO-Audit', href: '/compliance/audit', description: 'Audit-Dokumentation erstellen' },
{ name: 'Workflow', href: '/compliance/workflow', description: 'Freigabe-Prozesse konfigurieren' },
]}
collapsible={true}
defaultCollapsed={false}
/>
{/* Token Input */}
{!authToken && (
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">
Admin Token
</label>
<input
type="password"
placeholder="JWT Token eingeben..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
onChange={(e) => {
setAuthToken(e.target.value)
localStorage.setItem('bp_admin_token', e.target.value)
}}
/>
</div>
)}
{/* Tabs */}
<div className="mb-6">
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab.id
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Content */}
<div>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error}
<button
onClick={() => setError(null)}
className="ml-4 text-red-500 hover:text-red-700"
>
X
</button>
</div>
)}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
{/* Documents Tab */}
{activeTab === 'documents' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Dokumente verwalten</h2>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ Neues Dokument
</button>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Dokumente...</div>
) : documents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Dokumente vorhanden
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Typ</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Name</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Beschreibung</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Pflicht</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Erstellt</th>
<th className="text-right py-3 px-4 text-sm font-medium text-slate-500">Aktionen</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4">
<span className="px-2 py-1 bg-slate-100 text-slate-700 rounded text-xs font-medium">
{doc.type}
</span>
</td>
<td className="py-3 px-4 font-medium text-slate-900">{doc.name}</td>
<td className="py-3 px-4 text-slate-600 text-sm">{doc.description}</td>
<td className="py-3 px-4">
{doc.mandatory ? (
<span className="text-green-600">Ja</span>
) : (
<span className="text-slate-400">Nein</span>
)}
</td>
<td className="py-3 px-4 text-sm text-slate-500">
{new Date(doc.created_at).toLocaleDateString('de-DE')}
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => {
setSelectedDocument(doc.id)
setActiveTab('versions')
}}
className="text-purple-600 hover:text-purple-700 text-sm font-medium mr-3"
>
Versionen
</button>
<button className="text-slate-500 hover:text-slate-700 text-sm">
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Versions Tab */}
{activeTab === 'versions' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h2 className="text-lg font-semibold text-slate-900">Versionen</h2>
<select
value={selectedDocument}
onChange={(e) => setSelectedDocument(e.target.value)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Dokument auswaehlen...</option>
{documents.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.name}
</option>
))}
</select>
</div>
{selectedDocument && (
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ Neue Version
</button>
)}
</div>
{!selectedDocument ? (
<div className="text-center py-12 text-slate-500">
Bitte waehlen Sie ein Dokument aus
</div>
) : loading ? (
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
) : versions.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Versionen vorhanden
</div>
) : (
<div className="space-y-4">
{versions.map((version) => (
<div
key={version.id}
className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-slate-900">v{version.version}</span>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
{version.language.toUpperCase()}
</span>
<span
className={`px-2 py-0.5 rounded text-xs ${
version.status === 'published'
? 'bg-green-100 text-green-700'
: version.status === 'draft'
? 'bg-yellow-100 text-yellow-700'
: 'bg-slate-100 text-slate-600'
}`}
>
{version.status}
</span>
</div>
<h3 className="text-slate-700">{version.title}</h3>
<p className="text-sm text-slate-500 mt-1">
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
</p>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
{version.status === 'draft' && (
<button className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
Veroeffentlichen
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Emails Tab - 16 Lifecycle Templates */}
{activeTab === 'emails' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail Vorlagen</h2>
<p className="text-sm text-slate-500 mt-1">16 Lifecycle-Vorlagen fuer automatisierte Kommunikation</p>
</div>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ Neue Vorlage
</button>
</div>
{/* Category Filter */}
<div className="flex flex-wrap gap-2 mb-6">
<span className="text-sm text-slate-500 py-1">Filter:</span>
{emailCategories.map((cat) => (
<span key={cat.key} className={`px-3 py-1 rounded-full text-xs font-medium ${cat.color}`}>
{cat.label}
</span>
))}
</div>
{/* Templates grouped by category */}
{emailCategories.map((category) => (
<div key={category.key} className="mb-8">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${category.color.split(' ')[0]}`}></span>
{category.label}
</h3>
<div className="grid gap-3">
{emailTemplates
.filter((t) => t.category === category.key)
.map((template) => (
<div
key={template.key}
className="border border-slate-200 rounded-lg p-4 flex items-center justify-between hover:border-purple-300 transition-colors bg-white"
>
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-lg ${category.color} flex items-center justify-center text-lg`}>
{category.key === 'onboarding' && ''}
{category.key === 'security' && ''}
{category.key === 'consent' && ''}
{category.key === 'gdpr' && ''}
{category.key === 'lifecycle' && ''}
</div>
<div>
<h4 className="font-medium text-slate-900">{template.name}</h4>
<p className="text-sm text-slate-500">{template.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorschau
</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* GDPR Processes Tab - Articles 15-21 */}
{activeTab === 'gdpr' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">DSGVO Betroffenenrechte</h2>
<p className="text-sm text-slate-500 mt-1">Artikel 15-21 Prozesse und Vorlagen</p>
</div>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ DSR Anfrage erstellen
</button>
</div>
{/* Info Banner */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<span className="text-2xl">*</span>
<div>
<h4 className="font-medium text-purple-900">Data Subject Rights (DSR)</h4>
<p className="text-sm text-purple-700 mt-1">
Hier verwalten Sie alle DSGVO-Anfragen. Jeder Artikel hat definierte Prozesse, SLAs und automatisierte Workflows.
</p>
</div>
</div>
</div>
{/* GDPR Process Cards */}
<div className="space-y-4">
{gdprProcesses.map((process) => (
<div
key={process.article}
className="border border-slate-200 rounded-xl p-5 hover:border-purple-300 transition-colors bg-white"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center font-bold text-lg">
{process.article}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-slate-900">{process.title}</h3>
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
</div>
<p className="text-sm text-slate-600 mb-3">{process.description}</p>
{/* Actions */}
<div className="flex flex-wrap gap-2 mb-3">
{process.actions.map((action, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
{action}
</span>
))}
</div>
{/* SLA */}
<div className="flex items-center gap-4 text-sm">
<span className="text-slate-500">
SLA: <span className="font-medium text-slate-700">{process.sla}</span>
</span>
<span className="text-slate-300">|</span>
<span className="text-slate-500">
Offene Anfragen: <span className="font-medium text-slate-700">0</span>
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<button className="px-3 py-1.5 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg">
Anfragen
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorlage
</button>
</div>
</div>
</div>
))}
</div>
{/* DSR Request Statistics */}
<div className="mt-8 pt-6 border-t border-slate-200">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-4">DSR Uebersicht</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-slate-900">0</div>
<div className="text-xs text-slate-500 mt-1">Offen</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-700">0</div>
<div className="text-xs text-slate-500 mt-1">Erledigt</div>
</div>
<div className="bg-yellow-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-yellow-700">0</div>
<div className="text-xs text-slate-500 mt-1">In Bearbeitung</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-red-700">0</div>
<div className="text-xs text-slate-500 mt-1">Ueberfaellig</div>
</div>
</div>
</div>
</div>
)}
{/* Stats Tab */}
{activeTab === 'stats' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Statistiken</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Aktive Zustimmungen</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Dokumente</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Offene DSR-Anfragen</div>
</div>
</div>
<div className="border border-slate-200 rounded-lg p-6">
<h3 className="font-semibold text-slate-900 mb-4">Zustimmungsrate nach Dokument</h3>
<div className="text-center py-8 text-slate-500">
Noch keine Daten verfuegbar
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,484 +0,0 @@
'use client'
/**
* Control Catalogue Page
*
* Features:
* - List all 44+ controls with filters
* - Domain-based organization (9 domains)
* - Status update / Review workflow
* - Evidence linking
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
interface Control {
id: string
control_id: string
domain: string
control_type: string
title: string
description: string
pass_criteria: string
implementation_guidance: string
code_reference: string
is_automated: boolean
automation_tool: string
owner: string
status: string
status_notes: string
last_reviewed_at: string | null
next_review_at: string | null
evidence_count: number
}
const DOMAIN_LABELS: Record<string, string> = {
gov: 'Governance',
priv: 'Datenschutz',
iam: 'Identity & Access',
crypto: 'Kryptografie',
sdlc: 'Secure Dev',
ops: 'Operations',
ai: 'KI-spezifisch',
cra: 'Supply Chain',
aud: 'Audit',
}
const DOMAIN_COLORS: Record<string, string> = {
gov: 'bg-slate-100 text-slate-700',
priv: 'bg-blue-100 text-blue-700',
iam: 'bg-purple-100 text-purple-700',
crypto: 'bg-yellow-100 text-yellow-700',
sdlc: 'bg-green-100 text-green-700',
ops: 'bg-orange-100 text-orange-700',
ai: 'bg-pink-100 text-pink-700',
cra: 'bg-cyan-100 text-cyan-700',
aud: 'bg-indigo-100 text-indigo-700',
}
const STATUS_STYLES: Record<string, { bg: string; text: string; icon: string; label: string }> = {
pass: { bg: 'bg-green-100', text: 'text-green-700', icon: 'M5 13l4 4L19 7', label: 'Bestanden' },
partial: { bg: 'bg-yellow-100', text: 'text-yellow-700', icon: 'M12 8v4m0 4h.01', label: 'Teilweise' },
fail: { bg: 'bg-red-100', text: 'text-red-700', icon: 'M6 18L18 6M6 6l12 12', label: 'Nicht bestanden' },
planned: { bg: 'bg-slate-100', text: 'text-slate-700', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z', label: 'Geplant' },
'n/a': { bg: 'bg-slate-100', text: 'text-slate-500', icon: 'M20 12H4', label: 'Nicht anwendbar' },
}
export default function ControlsPage() {
const [controls, setControls] = useState<Control[]>([])
const [loading, setLoading] = useState(true)
const [selectedControl, setSelectedControl] = useState<Control | null>(null)
const [filterDomain, setFilterDomain] = useState<string>('')
const [filterStatus, setFilterStatus] = useState<string>('')
const [searchTerm, setSearchTerm] = useState('')
const [reviewModalOpen, setReviewModalOpen] = useState(false)
const [reviewStatus, setReviewStatus] = useState('pass')
const [reviewNotes, setReviewNotes] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
loadControls()
}, [filterDomain, filterStatus])
const loadControls = async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterDomain) params.append('domain', filterDomain)
if (filterStatus) params.append('status', filterStatus)
if (searchTerm) params.append('search', searchTerm)
const res = await fetch(`/api/admin/compliance/controls?${params}`)
if (res.ok) {
const data = await res.json()
setControls(data.controls || [])
}
} catch (error) {
console.error('Failed to load controls:', error)
} finally {
setLoading(false)
}
}
const handleSearch = () => {
loadControls()
}
const openReviewModal = (control: Control) => {
setSelectedControl(control)
setReviewStatus(control.status || 'planned')
setReviewNotes(control.status_notes || '')
setReviewModalOpen(true)
}
const submitReview = async () => {
if (!selectedControl) return
setSaving(true)
try {
const res = await fetch(`/api/admin/compliance/controls/${selectedControl.control_id}/review`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: reviewStatus,
status_notes: reviewNotes,
}),
})
if (res.ok) {
setReviewModalOpen(false)
loadControls()
} else {
alert('Fehler beim Speichern')
}
} catch (error) {
console.error('Review failed:', error)
alert('Fehler beim Speichern')
} finally {
setSaving(false)
}
}
const filteredControls = controls.filter((c) => {
if (searchTerm) {
const term = searchTerm.toLowerCase()
return (
c.control_id.toLowerCase().includes(term) ||
c.title.toLowerCase().includes(term) ||
(c.description && c.description.toLowerCase().includes(term))
)
}
return true
})
const getDaysUntilReview = (nextReview: string | null) => {
if (!nextReview) return null
const days = Math.ceil((new Date(nextReview).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
return days
}
// Statistics
const stats = {
total: controls.length,
pass: controls.filter(c => c.status === 'pass').length,
partial: controls.filter(c => c.status === 'partial').length,
fail: controls.filter(c => c.status === 'fail').length,
planned: controls.filter(c => c.status === 'planned').length,
automated: controls.filter(c => c.is_automated).length,
overdue: controls.filter(c => {
if (!c.next_review_at) return false
return new Date(c.next_review_at) < new Date()
}).length,
}
return (
<div className="min-h-screen bg-slate-50 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Control Catalogue</h1>
<p className="text-slate-600">Technische & organisatorische Massnahmen</p>
</div>
<Link
href="/compliance/hub"
className="flex items-center gap-2 text-slate-600 hover:text-slate-800"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Compliance Hub
</Link>
</div>
{/* Page Purpose */}
<PagePurpose
title="Control Catalogue"
purpose="Der Control-Katalog dokumentiert alle technischen und organisatorischen Massnahmen (TOMs) zur Einhaltung von ISO 27001, DSGVO, AI Act und BSI TR-03161. Jede Massnahme wird regelmaessig reviewed und mit Nachweisen verknuepft."
audience={['CISO', 'DSB', 'Compliance Officer', 'Entwickler']}
gdprArticles={['Art. 32 (Sicherheit)', 'Art. 25 (Privacy by Design)']}
architecture={{
services: ['Python Backend (FastAPI)', 'compliance_controls Modul'],
databases: ['PostgreSQL (compliance_controls Table)'],
}}
relatedPages={[
{ name: 'Evidence', href: '/compliance/evidence', description: 'Nachweise zu Controls verwalten' },
{ name: 'Audit Checklist', href: '/compliance/audit-checklist', description: 'Anforderungen pruefen' },
{ name: 'Risks', href: '/compliance/risks', description: 'Risiko-Matrix verwalten' },
]}
/>
{/* Statistics Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 border border-slate-200">
<p className="text-sm text-slate-500">Gesamt</p>
<p className="text-2xl font-bold text-slate-900">{stats.total}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-green-200">
<p className="text-sm text-green-600">Bestanden</p>
<p className="text-2xl font-bold text-green-700">{stats.pass}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-yellow-200">
<p className="text-sm text-yellow-600">Teilweise</p>
<p className="text-2xl font-bold text-yellow-700">{stats.partial}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-red-200">
<p className="text-sm text-red-600">Nicht bestanden</p>
<p className="text-2xl font-bold text-red-700">{stats.fail}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200">
<p className="text-sm text-slate-500">Geplant</p>
<p className="text-2xl font-bold text-slate-700">{stats.planned}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-blue-200">
<p className="text-sm text-blue-600">Automatisiert</p>
<p className="text-2xl font-bold text-blue-700">{stats.automated}</p>
</div>
<div className={`bg-white rounded-xl p-4 border ${stats.overdue > 0 ? 'border-red-300 bg-red-50' : 'border-slate-200'}`}>
<p className="text-sm text-slate-500">Review faellig</p>
<p className={`text-2xl font-bold ${stats.overdue > 0 ? 'text-red-600' : 'text-slate-700'}`}>
{stats.overdue}
</p>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-xl shadow-sm border p-4 mb-6">
<div className="flex flex-wrap items-center gap-4">
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="Control suchen (ID, Titel, Beschreibung)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<select
value={filterDomain}
onChange={(e) => setFilterDomain(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Domains</option>
{Object.entries(DOMAIN_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Status</option>
{Object.entries(STATUS_STYLES).map(([key, style]) => (
<option key={key} value={key}>{style.label}</option>
))}
</select>
<button
onClick={handleSearch}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Filtern
</button>
</div>
</div>
{/* Controls Table */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : filteredControls.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p className="text-slate-500">Keine Controls gefunden</p>
<p className="text-sm text-slate-400 mt-1">
Versuchen Sie andere Filter oder laden Sie die Compliance-Daten im Hub
</p>
</div>
) : (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="px-4 py-3 border-b bg-slate-50 flex justify-between items-center">
<span className="text-sm text-slate-500">{filteredControls.length} Controls</span>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Domain</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Automatisiert</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Nachweise</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Review</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{filteredControls.map((control) => {
const statusStyle = STATUS_STYLES[control.status] || STATUS_STYLES.planned
const daysUntilReview = getDaysUntilReview(control.next_review_at)
return (
<tr key={control.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-primary-600">{control.control_id}</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${DOMAIN_COLORS[control.domain] || 'bg-slate-100 text-slate-700'}`}>
{DOMAIN_LABELS[control.domain] || control.domain}
</span>
</td>
<td className="px-4 py-3">
<div>
<p className="font-medium text-slate-900">{control.title}</p>
{control.description && (
<p className="text-sm text-slate-500 truncate max-w-md">{control.description}</p>
)}
</div>
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full ${statusStyle.bg} ${statusStyle.text}`}>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={statusStyle.icon} />
</svg>
{statusStyle.label}
</span>
</td>
<td className="px-4 py-3 text-center">
{control.is_automated ? (
<span className="inline-flex items-center gap-1 text-green-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-xs">{control.automation_tool}</span>
</span>
) : (
<span className="text-slate-400 text-xs">Manuell</span>
)}
</td>
<td className="px-4 py-3 text-center">
<Link
href={`/compliance/evidence?control=${control.control_id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{control.evidence_count || 0}
</Link>
</td>
<td className="px-4 py-3 text-center">
{daysUntilReview !== null ? (
<span className={`text-sm ${
daysUntilReview < 0
? 'text-red-600 font-medium'
: daysUntilReview < 14
? 'text-yellow-600'
: 'text-slate-500'
}`}>
{daysUntilReview < 0
? `${Math.abs(daysUntilReview)}d ueberfaellig`
: `${daysUntilReview}d`}
</span>
) : (
<span className="text-slate-400 text-sm">-</span>
)}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => openReviewModal(control)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Review
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* Review Modal */}
{reviewModalOpen && selectedControl && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
<div className="p-6 border-b">
<h3 className="text-lg font-semibold text-slate-900">
Control Review
</h3>
<p className="text-sm text-slate-500 font-mono">{selectedControl.control_id}</p>
</div>
<div className="p-6 space-y-4">
<div>
<p className="font-medium text-slate-700 mb-1">{selectedControl.title}</p>
{selectedControl.pass_criteria && (
<div className="p-3 bg-slate-50 rounded-lg text-sm">
<p className="font-medium text-slate-600 mb-1">Pass-Kriterium:</p>
<p className="text-slate-600">{selectedControl.pass_criteria}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Status</label>
<div className="grid grid-cols-5 gap-2">
{Object.entries(STATUS_STYLES).map(([key, style]) => (
<button
key={key}
onClick={() => setReviewStatus(key)}
className={`p-2 rounded-lg border-2 text-xs font-medium transition-colors ${
reviewStatus === key
? `${style.bg} ${style.text} border-current`
: 'border-slate-200 hover:border-slate-300'
}`}
>
{style.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Notizen</label>
<textarea
value={reviewNotes}
onChange={(e) => setReviewNotes(e.target.value)}
placeholder="Begruendung, Nachweise, naechste Schritte..."
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<div className="p-6 border-t bg-slate-50 flex justify-end gap-3">
<button
onClick={() => setReviewModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={saving}
>
Abbrechen
</button>
<button
onClick={submitReview}
disabled={saving}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,691 +0,0 @@
'use client'
/**
* DSFA - Datenschutz-Folgenabschaetzung
*
* Art. 35 DSGVO - Datenschutz-Folgenabschaetzung
*/
import { useState } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface DSFAProject {
id: string
name: string
description: string
status: 'draft' | 'in_progress' | 'completed' | 'review_needed'
riskLevel: 'low' | 'medium' | 'high' | 'critical'
createdAt: string
lastUpdated: string
dpoApproval?: boolean
phases: {
description: boolean
necessity: boolean
risks: boolean
measures: boolean
consultation: boolean
}
}
interface RiskAssessment {
id: string
category: string
risk: string
likelihood: 'rare' | 'unlikely' | 'possible' | 'likely' | 'certain'
impact: 'negligible' | 'minor' | 'moderate' | 'major' | 'severe'
riskScore: number
mitigations: string[]
residualRisk: 'acceptable' | 'tolerable' | 'unacceptable'
}
export default function DSFAPage() {
const [activeTab, setActiveTab] = useState<'overview' | 'projects' | 'methodology' | 'templates'>('overview')
const [expandedProject, setExpandedProject] = useState<string | null>('ai_processing')
const dsfaProjects: DSFAProject[] = [
{
id: 'ai_processing',
name: 'KI-gestuetzte Korrektur und Bewertung',
description: 'Automatische Korrektur von Schuelerarbeiten mittels KI (Ollama/OpenAI)',
status: 'in_progress',
riskLevel: 'high',
createdAt: '2024-10-01',
lastUpdated: '2024-12-01',
dpoApproval: false,
phases: {
description: true,
necessity: true,
risks: true,
measures: false,
consultation: false
}
},
{
id: 'learning_analytics',
name: 'Lernfortschrittsanalyse',
description: 'Systematische Analyse des Lernverhaltens zur Personalisierung',
status: 'completed',
riskLevel: 'medium',
createdAt: '2024-06-15',
lastUpdated: '2024-11-15',
dpoApproval: true,
phases: {
description: true,
necessity: true,
risks: true,
measures: true,
consultation: true
}
},
{
id: 'biometric_voice',
name: 'Voice-Service Spracherkennung',
description: 'Sprachbasierte Interaktion mit potentieller Stimmerkennung',
status: 'draft',
riskLevel: 'high',
createdAt: '2024-11-01',
lastUpdated: '2024-11-01',
dpoApproval: false,
phases: {
description: true,
necessity: false,
risks: false,
measures: false,
consultation: false
}
},
]
const riskAssessments: RiskAssessment[] = [
{
id: 'r1',
category: 'Vertraulichkeit',
risk: 'Unbefugter Zugriff auf Schuelerdaten durch Drittanbieter-KI',
likelihood: 'unlikely',
impact: 'major',
riskScore: 12,
mitigations: [
'Lokale Verarbeitung mit Ollama priorisieren',
'Anonymisierung vor Cloud-Verarbeitung',
'Standardvertragsklauseln mit OpenAI'
],
residualRisk: 'tolerable'
},
{
id: 'r2',
category: 'Integritaet',
risk: 'Fehlerhafte KI-Bewertungen fuehren zu falschen Noten',
likelihood: 'possible',
impact: 'moderate',
riskScore: 9,
mitigations: [
'Menschliche Ueberpruefung aller KI-Bewertungen',
'Transparente Darstellung als "Vorschlag"',
'Feedback-Mechanismus fuer Korrekturen'
],
residualRisk: 'acceptable'
},
{
id: 'r3',
category: 'Verfuegbarkeit',
risk: 'Systemausfall verhindert Zugriff auf Lernmaterialien',
likelihood: 'rare',
impact: 'minor',
riskScore: 2,
mitigations: [
'Offline-Faehigkeit der App',
'Redundante Datenhaltung',
'Automatische Backups'
],
residualRisk: 'acceptable'
},
{
id: 'r4',
category: 'Rechte der Betroffenen',
risk: 'Automatisierte Entscheidungen ohne menschliche Intervention',
likelihood: 'possible',
impact: 'major',
riskScore: 12,
mitigations: [
'KI nur als Unterstuetzung, finale Entscheidung beim Lehrer',
'Recht auf menschliche Ueberpruefung dokumentiert',
'Transparente Information ueber KI-Einsatz'
],
residualRisk: 'tolerable'
},
]
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Abgeschlossen</span>
case 'in_progress':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">In Bearbeitung</span>
case 'draft':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">Entwurf</span>
case 'review_needed':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Pruefung erforderlich</span>
default:
return null
}
}
const getRiskBadge = (level: string) => {
switch (level) {
case 'critical':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Kritisch</span>
case 'high':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">Hoch</span>
case 'medium':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Mittel</span>
case 'low':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Niedrig</span>
default:
return null
}
}
const getResidualRiskBadge = (risk: string) => {
switch (risk) {
case 'acceptable':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Akzeptabel</span>
case 'tolerable':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Tolerierbar</span>
case 'unacceptable':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Nicht akzeptabel</span>
default:
return null
}
}
const calculatePhaseProgress = (phases: DSFAProject['phases']) => {
const total = Object.keys(phases).length
const completed = Object.values(phases).filter(Boolean).length
return Math.round((completed / total) * 100)
}
return (
<div>
<PagePurpose
title="Datenschutz-Folgenabschaetzung (DSFA)"
purpose="Systematische Risikoanalyse fuer Verarbeitungen mit hohem Risiko gemaess Art. 35 DSGVO. Dokumentiert Risiken, Massnahmen und DSB-Freigaben."
audience={['DSB', 'Projektleiter', 'Entwickler', 'Geschaeftsfuehrung']}
gdprArticles={['Art. 35 (Datenschutz-Folgenabschaetzung)', 'Art. 36 (Vorherige Konsultation)']}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'VVT', href: '/compliance/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'TOMs', href: '/compliance/tom', description: 'Technische Massnahmen' },
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management-System' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Tabs */}
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
<div className="flex gap-2">
{[
{ id: 'overview', label: 'Uebersicht' },
{ id: 'projects', label: 'DSFA-Projekte' },
{ id: 'methodology', label: 'Methodik' },
{ id: 'templates', label: 'Vorlagen' },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Statistics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{dsfaProjects.length}</div>
<div className="text-sm text-slate-500">DSFA-Projekte</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">
{dsfaProjects.filter(p => p.status === 'completed').length}
</div>
<div className="text-sm text-slate-500">Abgeschlossen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-blue-600">
{dsfaProjects.filter(p => p.status === 'in_progress').length}
</div>
<div className="text-sm text-slate-500">In Bearbeitung</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-orange-600">
{dsfaProjects.filter(p => p.riskLevel === 'high' || p.riskLevel === 'critical').length}
</div>
<div className="text-sm text-slate-500">Hohes Risiko</div>
</div>
</div>
{/* When is DSFA required */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Wann ist eine DSFA erforderlich?</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<h3 className="font-medium text-slate-700">Art. 35 Abs. 3 - Pflichtfaelle:</h3>
<ul className="space-y-2 text-sm text-slate-600">
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
Systematische Bewertung persoenlicher Aspekte (Profiling)
</li>
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
Umfangreiche Verarbeitung besonderer Kategorien (Art. 9)
</li>
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
Systematische Ueberwachung oeffentlicher Bereiche
</li>
</ul>
</div>
<div className="space-y-3">
<h3 className="font-medium text-slate-700">Zusaetzliche Kriterien (DSK-Liste):</h3>
<ul className="space-y-2 text-sm text-slate-600">
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Verarbeitung von Daten Minderjaehriger
</li>
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Einsatz neuer Technologien (z.B. KI)
</li>
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Zusammenfuehrung von Datensaetzen
</li>
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Automatisierte Entscheidungsfindung
</li>
</ul>
</div>
</div>
</div>
{/* Risk Matrix */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Risiko-Matrix (KI-Verarbeitung)</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 font-medium text-slate-500">Kategorie</th>
<th className="text-left py-3 px-4 font-medium text-slate-500">Risiko</th>
<th className="text-left py-3 px-4 font-medium text-slate-500">Score</th>
<th className="text-left py-3 px-4 font-medium text-slate-500">Massnahmen</th>
<th className="text-left py-3 px-4 font-medium text-slate-500">Restrisiko</th>
</tr>
</thead>
<tbody>
{riskAssessments.map(risk => (
<tr key={risk.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4 font-medium text-slate-900">{risk.category}</td>
<td className="py-3 px-4 text-slate-600">{risk.risk}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${
risk.riskScore >= 12 ? 'bg-red-100 text-red-800' :
risk.riskScore >= 6 ? 'bg-yellow-100 text-yellow-800' :
'bg-green-100 text-green-800'
}`}>
{risk.riskScore}
</span>
</td>
<td className="py-3 px-4">
<ul className="text-xs text-slate-600 space-y-1">
{risk.mitigations.slice(0, 2).map((m, i) => (
<li key={i}> {m}</li>
))}
{risk.mitigations.length > 2 && (
<li className="text-slate-400">+{risk.mitigations.length - 2} weitere</li>
)}
</ul>
</td>
<td className="py-3 px-4">{getResidualRiskBadge(risk.residualRisk)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Projects Tab */}
{activeTab === 'projects' && (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">DSFA-Projekte</h2>
<p className="text-sm text-slate-500">{dsfaProjects.length} dokumentierte Folgenabschaetzungen</p>
</div>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700">
+ Neue DSFA
</button>
</div>
{dsfaProjects.map(project => (
<div key={project.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setExpandedProject(expandedProject === project.id ? null : project.id)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="text-left">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-slate-900">{project.name}</h3>
{getStatusBadge(project.status)}
{getRiskBadge(project.riskLevel)}
{project.dpoApproval && (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
DSB-Freigabe
</span>
)}
</div>
<p className="text-sm text-slate-500 mt-1">{project.description}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right text-sm text-slate-500">
<div>{calculatePhaseProgress(project.phases)}% abgeschlossen</div>
</div>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedProject === project.id ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{expandedProject === project.id && (
<div className="px-6 pb-6 border-t border-slate-100">
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Phases */}
<div>
<h4 className="text-sm font-medium text-slate-500 mb-3">DSFA-Phasen</h4>
<div className="space-y-2">
{[
{ key: 'description', label: 'Beschreibung der Verarbeitung' },
{ key: 'necessity', label: 'Notwendigkeit & Verhaeltnismaessigkeit' },
{ key: 'risks', label: 'Risikobewertung' },
{ key: 'measures', label: 'Abhilfemassnahmen' },
{ key: 'consultation', label: 'DSB-Konsultation' },
].map(phase => (
<div key={phase.key} className="flex items-center gap-3">
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${
project.phases[phase.key as keyof typeof project.phases]
? 'bg-green-100 text-green-600'
: 'bg-slate-100 text-slate-400'
}`}>
{project.phases[phase.key as keyof typeof project.phases] ? (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<span className="w-2 h-2 rounded-full bg-slate-300" />
)}
</div>
<span className={project.phases[phase.key as keyof typeof project.phases] ? 'text-slate-900' : 'text-slate-500'}>
{phase.label}
</span>
</div>
))}
</div>
</div>
{/* Meta Info */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Erstellt</h4>
<p className="text-slate-700">{project.createdAt}</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Letzte Aktualisierung</h4>
<p className="text-slate-700">{project.lastUpdated}</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">DSB-Freigabe</h4>
<p className={project.dpoApproval ? 'text-green-600 font-medium' : 'text-yellow-600'}>
{project.dpoApproval ? 'Erteilt' : 'Ausstehend'}
</p>
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-100 flex gap-2">
<button className="px-3 py-1.5 text-sm text-purple-600 hover:text-purple-700 font-medium">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-700">
PDF exportieren
</button>
{!project.dpoApproval && (
<button className="px-3 py-1.5 text-sm text-green-600 hover:text-green-700">
Zur DSB-Freigabe einreichen
</button>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Methodology Tab */}
{activeTab === 'methodology' && (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">DSFA-Prozess nach Art. 35 DSGVO</h2>
<div className="space-y-6">
{[
{
step: 1,
title: 'Schwellwertanalyse',
description: 'Pruefung ob eine DSFA erforderlich ist anhand der Kriterien aus Art. 35 Abs. 3 und der DSK-Positivliste.',
details: [
'Verarbeitung besonderer Kategorien (Art. 9)?',
'Systematisches Profiling?',
'Neue Technologien im Einsatz?',
'Daten von Minderjaehrigen?'
]
},
{
step: 2,
title: 'Beschreibung der Verarbeitung',
description: 'Systematische Beschreibung der geplanten Verarbeitungsvorgaenge und Zwecke.',
details: [
'Art, Umfang, Umstaende der Verarbeitung',
'Zweck der Verarbeitung',
'Betroffene Personengruppen',
'Verantwortlichkeiten'
]
},
{
step: 3,
title: 'Notwendigkeit & Verhaeltnismaessigkeit',
description: 'Bewertung ob die Verarbeitung notwendig und verhaeltnismaessig ist.',
details: [
'Rechtsgrundlage vorhanden?',
'Zweckbindung eingehalten?',
'Datenminimierung beachtet?',
'Speicherbegrenzung definiert?'
]
},
{
step: 4,
title: 'Risikobewertung',
description: 'Systematische Bewertung der Risiken fuer Rechte und Freiheiten der Betroffenen.',
details: [
'Risiken identifizieren',
'Eintrittswahrscheinlichkeit bewerten',
'Schwere der Auswirkungen bewerten',
'Risiko-Score berechnen'
]
},
{
step: 5,
title: 'Abhilfemassnahmen',
description: 'Definition von Massnahmen zur Eindaemmung der identifizierten Risiken.',
details: [
'Technische Massnahmen (TOMs)',
'Organisatorische Massnahmen',
'Restrisiko-Bewertung',
'Implementierungsplan'
]
},
{
step: 6,
title: 'DSB-Konsultation',
description: 'Einholung der Stellungnahme des Datenschutzbeauftragten.',
details: [
'DSFA dem DSB vorlegen',
'Stellungnahme dokumentieren',
'Ggf. Anpassungen vornehmen',
'Freigabe erteilen'
]
},
{
step: 7,
title: 'Vorherige Konsultation (Art. 36)',
description: 'Bei verbleibendem hohen Risiko: Konsultation der Aufsichtsbehoerde.',
details: [
'Nur bei hohem Restrisiko erforderlich',
'Aufsichtsbehoerde hat 8 Wochen zur Pruefung',
'Dokumentation der Konsultation',
'Umsetzung der Auflagen'
]
}
].map(item => (
<div key={item.step} className="flex gap-4">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-purple-100 text-purple-700 flex items-center justify-center font-bold">
{item.step}
</div>
<div className="flex-grow">
<h3 className="font-semibold text-slate-900">{item.title}</h3>
<p className="text-sm text-slate-600 mt-1">{item.description}</p>
<ul className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-slate-500">
{item.details.map((detail, idx) => (
<li key={idx} className="flex items-center gap-1">
<span className="text-purple-400"></span> {detail}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Templates Tab */}
{activeTab === 'templates' && (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">DSFA-Vorlagen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
name: 'Standard DSFA-Vorlage',
description: 'Vollstaendige Vorlage nach Art. 35 DSGVO',
format: 'DOCX',
size: '45 KB'
},
{
name: 'KI-Verarbeitung Template',
description: 'Spezialvorlage fuer KI/ML-Anwendungen',
format: 'DOCX',
size: '52 KB'
},
{
name: 'Risikobewertungs-Matrix',
description: 'Excel-Vorlage fuer systematische Risikobewertung',
format: 'XLSX',
size: '28 KB'
},
{
name: 'Schwellwert-Checkliste',
description: 'Checkliste zur Pruefung ob DSFA erforderlich',
format: 'PDF',
size: '120 KB'
},
{
name: 'DSB-Konsultationsformular',
description: 'Formular zur internen DSB-Freigabe',
format: 'DOCX',
size: '32 KB'
},
{
name: 'Aufsichtsbehoerden-Vorlage',
description: 'Vorlage fuer Art. 36 Konsultation',
format: 'DOCX',
size: '38 KB'
}
].map(template => (
<div key={template.name} className="p-4 border border-slate-200 rounded-lg hover:border-purple-300 transition-colors">
<div className="flex items-start justify-between">
<div>
<h3 className="font-medium text-slate-900">{template.name}</h3>
<p className="text-sm text-slate-500 mt-1">{template.description}</p>
</div>
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs font-medium">
{template.format}
</span>
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-xs text-slate-400">{template.size}</span>
<button className="text-sm text-purple-600 hover:text-purple-700 font-medium">
Herunterladen
</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Info */}
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h4 className="font-semibold text-yellow-900">Wichtiger Hinweis</h4>
<p className="text-sm text-yellow-800 mt-1">
Eine DSFA ist <strong>vor</strong> Beginn der Verarbeitung durchzufuehren. Bei wesentlichen Aenderungen
an bestehenden Verarbeitungen muss die DSFA aktualisiert werden. Die Dokumentation muss
der Aufsichtsbehoerde auf Anfrage vorgelegt werden koennen.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,377 +0,0 @@
'use client'
/**
* DSMS (Data Protection Management System) Admin Page
*
* Central hub for data protection compliance management
*/
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
interface ComplianceModule {
id: string
title: string
description: string
status: 'active' | 'pending' | 'inactive'
href?: string
items: {
name: string
status: 'complete' | 'in_progress' | 'pending'
lastUpdated?: string
}[]
}
export default function DSMSPage() {
const modules: ComplianceModule[] = [
{
id: 'legal-docs',
title: 'Rechtliche Dokumente',
description: 'AGB, Datenschutzerklaerung, Cookie-Richtlinie',
status: 'active',
href: '/compliance/consent',
items: [
{ name: 'AGB', status: 'complete', lastUpdated: '2024-12-01' },
{ name: 'Datenschutzerklaerung', status: 'complete', lastUpdated: '2024-12-01' },
{ name: 'Cookie-Richtlinie', status: 'complete', lastUpdated: '2024-12-01' },
{ name: 'Impressum', status: 'complete', lastUpdated: '2024-12-01' },
],
},
{
id: 'dsr',
title: 'Betroffenenanfragen (DSR)',
description: 'Art. 15-21 DSGVO Anfragen-Management',
status: 'active',
href: '/compliance/dsr',
items: [
{ name: 'Auskunftsprozess (Art. 15)', status: 'complete' },
{ name: 'Berichtigung (Art. 16)', status: 'complete' },
{ name: 'Loeschung (Art. 17)', status: 'complete' },
{ name: 'Datenuebertragbarkeit (Art. 20)', status: 'complete' },
],
},
{
id: 'consent',
title: 'Einwilligungsverwaltung',
description: 'Consent-Tracking und -Nachweis',
status: 'active',
href: '/compliance/consent',
items: [
{ name: 'Consent-Datenbank', status: 'complete' },
{ name: 'Widerrufsprozess', status: 'complete' },
{ name: 'Audit-Trail', status: 'complete' },
{ name: 'Export-Funktion', status: 'complete' },
],
},
{
id: 'tom',
title: 'Technische & Organisatorische Massnahmen',
description: 'Art. 32 DSGVO Sicherheitsmassnahmen',
status: 'active',
href: '/compliance/tom',
items: [
{ name: 'Verschluesselung (TLS/Ruhe)', status: 'complete' },
{ name: 'Zugriffskontrolle', status: 'complete' },
{ name: 'Backup & Recovery', status: 'in_progress' },
{ name: 'Logging & Monitoring', status: 'complete' },
],
},
{
id: 'vvt',
title: 'Verarbeitungsverzeichnis',
description: 'Art. 30 DSGVO Dokumentation',
status: 'active',
href: '/compliance/vvt',
items: [
{ name: 'Verarbeitungstaetigkeiten', status: 'complete' },
{ name: 'Rechtsgrundlagen', status: 'complete' },
{ name: 'Loeschfristen', status: 'complete' },
{ name: 'Auftragsverarbeiter', status: 'complete' },
],
},
{
id: 'dpia',
title: 'Datenschutz-Folgenabschaetzung',
description: 'Art. 35 DSGVO Risikoanalyse',
status: 'active',
href: '/compliance/dsfa',
items: [
{ name: 'KI-Verarbeitung', status: 'in_progress' },
{ name: 'Profiling-Risiken', status: 'complete' },
{ name: 'Automatisierte Entscheidungen', status: 'in_progress' },
],
},
]
// Get status badge
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
case 'complete':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Aktiv</span>
case 'in_progress':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">In Arbeit</span>
case 'pending':
case 'inactive':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">Ausstehend</span>
default:
return null
}
}
// Calculate overall compliance score
const calculateScore = () => {
let complete = 0
let total = 0
modules.forEach((m) => {
m.items.forEach((item) => {
total++
if (item.status === 'complete') complete++
})
})
return Math.round((complete / total) * 100)
}
const complianceScore = calculateScore()
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Datenschutz-Management-System (DSMS)"
purpose="Zentrale Uebersicht aller Datenschutz-Massnahmen und deren Status. Hier verfolgen Sie den Compliance-Fortschritt und identifizieren offene Aufgaben."
audience={['DSB', 'Compliance Officer', 'Geschaeftsfuehrung']}
gdprArticles={[
'Art. 5 (Grundsaetze)',
'Art. 24 (Verantwortung)',
'Art. 30 (Verarbeitungsverzeichnis)',
'Art. 32 (Sicherheit)',
'Art. 35 (DSFA)',
]}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'Consent', href: '/compliance/consent', description: 'Dokumente und Versionen' },
{ name: 'DSR', href: '/compliance/dsr', description: 'Betroffenenanfragen' },
{ name: 'TOMs', href: '/compliance/tom', description: 'Technische Massnahmen' },
{ name: 'VVT', href: '/compliance/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'DSFA', href: '/compliance/dsfa', description: 'Datenschutz-Folgenabschaetzung' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Compliance Score */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">DSGVO-Compliance Score</h2>
<p className="text-sm text-slate-500 mt-1">Gesamtfortschritt der Datenschutz-Massnahmen</p>
</div>
<div className="text-right">
<div className={`text-4xl font-bold ${complianceScore >= 80 ? 'text-green-600' : complianceScore >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
{complianceScore}%
</div>
<div className="text-sm text-slate-500">Compliance</div>
</div>
</div>
<div className="mt-4 h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${complianceScore >= 80 ? 'bg-green-500' : complianceScore >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${complianceScore}%` }}
/>
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Link
href="/compliance/dsr"
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-purple-300 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-orange-100 flex items-center justify-center">
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<div className="font-medium text-slate-900">DSR bearbeiten</div>
<div className="text-xs text-slate-500">Anfragen verwalten</div>
</div>
</div>
</Link>
<Link
href="/compliance/consent"
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-purple-300 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="font-medium text-slate-900">Consents</div>
<div className="text-xs text-slate-500">Einwilligungen pruefen</div>
</div>
</div>
</Link>
<Link
href="/compliance/einwilligungen"
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-purple-300 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="font-medium text-slate-900">Einwilligungen</div>
<div className="text-xs text-slate-500">User Consents pruefen</div>
</div>
</div>
</Link>
<Link
href="/compliance/loeschfristen"
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-purple-300 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="font-medium text-slate-900">Loeschfristen</div>
<div className="text-xs text-slate-500">Pruefen & durchfuehren</div>
</div>
</div>
</Link>
</div>
{/* Audit Report Quick Action */}
<div className="mb-6">
<Link
href="/compliance/audit-report"
className="block bg-gradient-to-r from-purple-500 to-indigo-600 rounded-xl p-6 text-white hover:from-purple-600 hover:to-indigo-700 transition-all"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-white/20 flex items-center justify-center">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold">Audit Report erstellen</h3>
<p className="text-sm text-white/80">PDF-Berichte fuer Auditoren und Aufsichtsbehoerden generieren</p>
</div>
</div>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
</div>
{/* Compliance Modules */}
<h2 className="text-lg font-semibold text-slate-900 mb-4">Compliance-Module</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{modules.map((module) => (
<div key={module.id} className="bg-white rounded-xl border border-slate-200">
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-900">{module.title}</h3>
<p className="text-xs text-slate-500">{module.description}</p>
</div>
{getStatusBadge(module.status)}
</div>
<div className="p-4">
<ul className="space-y-2">
{module.items.map((item, idx) => (
<li key={idx} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
{item.status === 'complete' ? (
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : item.status === 'in_progress' ? (
<svg className="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-4 h-4 text-slate-300" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM7 9a1 1 0 000 2h6a1 1 0 100-2H7z" clipRule="evenodd" />
</svg>
)}
<span className={item.status === 'pending' ? 'text-slate-400' : 'text-slate-700'}>
{item.name}
</span>
</div>
{item.lastUpdated && (
<span className="text-xs text-slate-400">{item.lastUpdated}</span>
)}
</li>
))}
</ul>
{module.href && (
<Link
href={module.href}
className="mt-3 block text-center py-2 text-sm text-purple-600 hover:text-purple-700 font-medium"
>
Verwalten
</Link>
)}
</div>
</div>
))}
</div>
{/* GDPR Rights Overview */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-6">
<h3 className="font-semibold text-purple-900 mb-4">DSGVO Betroffenenrechte (Art. 12-22)</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="font-medium text-purple-700">Art. 15</div>
<div className="text-purple-600">Auskunftsrecht</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 16</div>
<div className="text-purple-600">Recht auf Berichtigung</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 17</div>
<div className="text-purple-600">Recht auf Loeschung</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 18</div>
<div className="text-purple-600">Recht auf Einschraenkung</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 19</div>
<div className="text-purple-600">Mitteilungspflicht</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 20</div>
<div className="text-purple-600">Datenuebertragbarkeit</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 21</div>
<div className="text-purple-600">Widerspruchsrecht</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 22</div>
<div className="text-purple-600">Automatisierte Entscheidungen</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,429 +0,0 @@
'use client'
/**
* DSR (Data Subject Requests) Admin Page
*
* GDPR Article 15-21 Request Management
*/
import { useEffect, useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface DSRRequest {
id: string
request_number: string
requester_email: string
requester_name: string
request_type: string
status: string
priority: string
created_at: string
deadline: string
assigned_to?: string
notes?: string
}
interface DSRStats {
total: number
pending: number
in_progress: number
completed: number
overdue: number
}
export default function DSRPage() {
const [adminToken, setAdminToken] = useState('')
const [requests, setRequests] = useState<DSRRequest[]>([])
const [stats, setStats] = useState<DSRStats | null>(null)
const [loading, setLoading] = useState(false)
const [selectedRequest, setSelectedRequest] = useState<DSRRequest | null>(null)
const [filter, setFilter] = useState<string>('all')
const [error, setError] = useState<string | null>(null)
const API_BASE = 'http://localhost:8081/api/v1'
// Load saved token
useEffect(() => {
const savedToken = localStorage.getItem('adminToken')
if (savedToken) {
setAdminToken(savedToken)
}
}, [])
// Save token
const saveToken = (token: string) => {
setAdminToken(token)
localStorage.setItem('adminToken', token)
}
// Fetch DSR requests
const fetchRequests = useCallback(async () => {
if (!adminToken) return
setLoading(true)
setError(null)
try {
const response = await fetch(`${API_BASE}/dsr/requests`, {
headers: {
'Authorization': `Bearer ${adminToken}`,
},
})
if (!response.ok) {
if (response.status === 401) {
throw new Error('Nicht autorisiert - Token ungueltig')
}
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
setRequests(data.requests || [])
// Calculate stats
const allRequests = data.requests || []
const now = new Date()
setStats({
total: allRequests.length,
pending: allRequests.filter((r: DSRRequest) => r.status === 'pending').length,
in_progress: allRequests.filter((r: DSRRequest) => r.status === 'in_progress').length,
completed: allRequests.filter((r: DSRRequest) => r.status === 'completed').length,
overdue: allRequests.filter((r: DSRRequest) => new Date(r.deadline) < now && r.status !== 'completed').length,
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [adminToken])
useEffect(() => {
if (adminToken) {
fetchRequests()
}
}, [adminToken, fetchRequests])
// Get status badge color
const getStatusColor = (status: string) => {
switch (status) {
case 'pending':
return 'bg-yellow-100 text-yellow-800'
case 'in_progress':
return 'bg-blue-100 text-blue-800'
case 'completed':
return 'bg-green-100 text-green-800'
case 'rejected':
return 'bg-red-100 text-red-800'
default:
return 'bg-slate-100 text-slate-800'
}
}
// Get priority badge color
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent':
return 'bg-red-100 text-red-800'
case 'high':
return 'bg-orange-100 text-orange-800'
case 'normal':
return 'bg-slate-100 text-slate-800'
case 'low':
return 'bg-slate-50 text-slate-600'
default:
return 'bg-slate-100 text-slate-800'
}
}
// Get request type label
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
'access': 'Auskunft (Art. 15)',
'rectification': 'Berichtigung (Art. 16)',
'erasure': 'Loeschung (Art. 17)',
'restriction': 'Einschraenkung (Art. 18)',
'portability': 'Datenuebertragbarkeit (Art. 20)',
'objection': 'Widerspruch (Art. 21)',
}
return labels[type] || type
}
// Filter requests
const filteredRequests = requests.filter(r => {
if (filter === 'all') return true
if (filter === 'overdue') {
return new Date(r.deadline) < new Date() && r.status !== 'completed'
}
return r.status === filter
})
// Format date
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
// Check if overdue
const isOverdue = (deadline: string, status: string) => {
return new Date(deadline) < new Date() && status !== 'completed'
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Datenschutzanfragen (DSR)"
purpose="Verwalten Sie alle Betroffenenanfragen nach DSGVO Art. 15-21. Hier bearbeiten Sie Auskunfts-, Loesch- und Berichtigungsanfragen mit automatischer Fristueberwachung."
audience={['DSB', 'Compliance Officer', 'Support']}
gdprArticles={[
'Art. 15 (Auskunftsrecht)',
'Art. 16 (Berichtigung)',
'Art. 17 (Loeschung)',
'Art. 18 (Einschraenkung)',
'Art. 20 (Datenuebertragbarkeit)',
'Art. 21 (Widerspruch)',
]}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'Consent Verwaltung', href: '/compliance/consent', description: 'Dokumente und Zustimmungen' },
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management-System' },
{ name: 'Audit', href: '/compliance/audit', description: 'Audit-Dokumentation' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Token Input */}
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">
Admin Token
</label>
<div className="flex gap-2">
<input
type="password"
value={adminToken}
onChange={(e) => saveToken(e.target.value)}
placeholder="JWT Token eingeben..."
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
<button
onClick={fetchRequests}
disabled={!adminToken || loading}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50"
>
{loading ? 'Laden...' : 'Laden'}
</button>
</div>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{stats.total}</div>
<div className="text-sm text-slate-500">Gesamt</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-yellow-600">{stats.pending}</div>
<div className="text-sm text-slate-500">Offen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-blue-600">{stats.in_progress}</div>
<div className="text-sm text-slate-500">In Bearbeitung</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
<div className="text-sm text-slate-500">Abgeschlossen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className={`text-2xl font-bold ${stats.overdue > 0 ? 'text-red-600' : 'text-slate-400'}`}>
{stats.overdue}
</div>
<div className="text-sm text-slate-500">Ueberfaellig</div>
</div>
</div>
)}
{/* Filter Tabs */}
<div className="flex gap-2 mb-4 overflow-x-auto">
{[
{ value: 'all', label: 'Alle' },
{ value: 'pending', label: 'Offen' },
{ value: 'in_progress', label: 'In Bearbeitung' },
{ value: 'completed', label: 'Abgeschlossen' },
{ value: 'overdue', label: 'Ueberfaellig' },
].map((tab) => (
<button
key={tab.value}
onClick={() => setFilter(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
filter === tab.value
? 'bg-purple-600 text-white'
: 'bg-white text-slate-700 border border-slate-200 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Requests Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Nr.</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Anfragesteller</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Prioritaet</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Frist</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredRequests.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
Keine Anfragen gefunden
</td>
</tr>
) : (
filteredRequests.map((request) => (
<tr key={request.id} className={isOverdue(request.deadline, request.status) ? 'bg-red-50' : ''}>
<td className="px-4 py-3 text-sm font-mono text-slate-900">{request.request_number}</td>
<td className="px-4 py-3 text-sm text-slate-700">{getTypeLabel(request.request_type)}</td>
<td className="px-4 py-3">
<div className="text-sm text-slate-900">{request.requester_name}</div>
<div className="text-xs text-slate-500">{request.requester_email}</div>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(request.status)}`}>
{request.status}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityColor(request.priority)}`}>
{request.priority}
</span>
</td>
<td className="px-4 py-3">
<span className={`text-sm ${isOverdue(request.deadline, request.status) ? 'text-red-600 font-medium' : 'text-slate-700'}`}>
{formatDate(request.deadline)}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={() => setSelectedRequest(request)}
className="text-purple-600 hover:text-purple-700 text-sm font-medium"
>
Details
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Detail Modal */}
{selectedRequest && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">
Anfrage {selectedRequest.request_number}
</h3>
<button
onClick={() => setSelectedRequest(null)}
className="text-slate-400 hover:text-slate-600"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-slate-500">Typ</div>
<div className="font-medium text-slate-900">{getTypeLabel(selectedRequest.request_type)}</div>
</div>
<div>
<div className="text-sm text-slate-500">Status</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(selectedRequest.status)}`}>
{selectedRequest.status}
</span>
</div>
<div>
<div className="text-sm text-slate-500">Anfragesteller</div>
<div className="font-medium text-slate-900">{selectedRequest.requester_name}</div>
<div className="text-sm text-slate-500">{selectedRequest.requester_email}</div>
</div>
<div>
<div className="text-sm text-slate-500">Frist</div>
<div className={`font-medium ${isOverdue(selectedRequest.deadline, selectedRequest.status) ? 'text-red-600' : 'text-slate-900'}`}>
{formatDate(selectedRequest.deadline)}
</div>
</div>
<div>
<div className="text-sm text-slate-500">Eingegangen</div>
<div className="font-medium text-slate-900">{formatDate(selectedRequest.created_at)}</div>
</div>
<div>
<div className="text-sm text-slate-500">Zugewiesen an</div>
<div className="font-medium text-slate-900">{selectedRequest.assigned_to || '-'}</div>
</div>
</div>
{selectedRequest.notes && (
<div>
<div className="text-sm text-slate-500 mb-1">Notizen</div>
<div className="bg-slate-50 rounded-lg p-3 text-sm text-slate-700">
{selectedRequest.notes}
</div>
</div>
)}
<div className="flex gap-2 pt-4">
<button className="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700">
Bearbeiten
</button>
<button className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50">
Abschliessen
</button>
</div>
</div>
</div>
</div>
)}
{/* Info Box */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<h4 className="font-semibold text-purple-900 mb-2">DSGVO-Fristen</h4>
<ul className="text-sm text-purple-800 space-y-1">
<li>Art. 15 (Auskunft): 1 Monat, verlaengerbar auf 3 Monate</li>
<li>Art. 16 (Berichtigung): Unverzueglich</li>
<li>Art. 17 (Loeschung): Unverzueglich</li>
<li>Art. 18 (Einschraenkung): Unverzueglich</li>
<li>Art. 20 (Datenuebertragbarkeit): 1 Monat</li>
<li>Art. 21 (Widerspruch): Unverzueglich</li>
</ul>
</div>
</div>
)
}

View File

@@ -1,498 +0,0 @@
'use client'
/**
* Einwilligungsverwaltung - User Consent Management
*
* Zentrale Uebersicht aller Nutzer-Einwilligungen aus:
* - Website
* - App
* - PWA
*
* Kategorien: Marketing, Statistik, Cookies, Rechtliche Dokumente
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
const API_BASE = '/api/admin/consent'
type Tab = 'overview' | 'documents' | 'cookies' | 'marketing' | 'audit'
interface ConsentStats {
total_users: number
consented_users: number
consent_rate: number
pending_consents: number
}
interface AuditEntry {
id: string
user_id: string
action: string
entity_type: string
entity_id: string
details: Record<string, unknown>
ip_address: string
created_at: string
}
interface ConsentSummary {
category: string
total: number
accepted: number
declined: number
pending: number
rate: number
}
export default function EinwilligungenPage() {
const [activeTab, setActiveTab] = useState<Tab>('overview')
const [stats, setStats] = useState<ConsentStats | null>(null)
const [auditLog, setAuditLog] = useState<AuditEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [authToken, setAuthToken] = useState<string>('')
useEffect(() => {
const token = localStorage.getItem('bp_admin_token')
if (token) {
setAuthToken(token)
}
}, [])
useEffect(() => {
if (activeTab === 'overview') {
loadStats()
} else if (activeTab === 'audit') {
loadAuditLog()
}
}, [activeTab, authToken])
async function loadStats() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/stats`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setStats(data)
} else {
setError('Fehler beim Laden der Statistiken')
}
} catch {
setError('Verbindungsfehler')
} finally {
setLoading(false)
}
}
async function loadAuditLog() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/audit-log?limit=50`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setAuditLog(data.entries || [])
} else {
setError('Fehler beim Laden des Audit-Logs')
}
} catch {
setError('Verbindungsfehler')
} finally {
setLoading(false)
}
}
// Mock data for consent summary (in production, this comes from API)
const consentSummary: ConsentSummary[] = [
{ category: 'AGB', total: 1250, accepted: 1248, declined: 0, pending: 2, rate: 99.8 },
{ category: 'Datenschutz', total: 1250, accepted: 1245, declined: 3, pending: 2, rate: 99.6 },
{ category: 'Cookies (Notwendig)', total: 1250, accepted: 1250, declined: 0, pending: 0, rate: 100 },
{ category: 'Cookies (Analyse)', total: 1250, accepted: 892, declined: 358, pending: 0, rate: 71.4 },
{ category: 'Cookies (Marketing)', total: 1250, accepted: 456, declined: 794, pending: 0, rate: 36.5 },
{ category: 'Newsletter', total: 1250, accepted: 312, declined: 938, pending: 0, rate: 25.0 },
]
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'documents', label: 'Dokumenten-Consents' },
{ id: 'cookies', label: 'Cookie-Consents' },
{ id: 'marketing', label: 'Marketing-Consents' },
{ id: 'audit', label: 'Audit-Trail' },
]
const getActionLabel = (action: string) => {
const labels: Record<string, string> = {
'consent_given': 'Zustimmung erteilt',
'consent_withdrawn': 'Zustimmung widerrufen',
'cookie_consent_updated': 'Cookie-Einstellungen aktualisiert',
'data_access': 'Datenzugriff',
'data_export_requested': 'Datenexport angefordert',
'data_deletion_requested': 'Loeschung angefordert',
'account_suspended': 'Account gesperrt',
'account_restored': 'Account wiederhergestellt',
}
return labels[action] || action
}
const getActionColor = (action: string) => {
if (action.includes('given') || action.includes('restored')) return 'bg-green-100 text-green-700'
if (action.includes('withdrawn') || action.includes('deleted') || action.includes('suspended')) return 'bg-red-100 text-red-700'
return 'bg-blue-100 text-blue-700'
}
return (
<div>
<PagePurpose
title="Einwilligungsverwaltung"
purpose="Zentrale Uebersicht aller Nutzer-Einwilligungen. Hier sehen Sie alle Zustimmungen zu rechtlichen Dokumenten, Cookies, Marketing und Statistik - erfasst ueber Website, App und PWA."
audience={['DSB', 'Compliance Officer', 'Marketing']}
gdprArticles={['Art. 6 (Rechtmaessigkeit)', 'Art. 7 (Einwilligung)', 'Art. 21 (Widerspruch)']}
architecture={{
services: ['consent-service (Go)'],
databases: ['PostgreSQL (user_consents, cookie_consents)'],
}}
relatedPages={[
{ name: 'Consent Dokumente', href: '/compliance/consent', description: 'Rechtliche Dokumente verwalten' },
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management' },
{ name: 'DSR', href: '/compliance/dsr', description: 'Betroffenenanfragen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{stats?.total_users || 1250}</div>
<div className="text-sm text-slate-500">Registrierte Nutzer</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats?.consented_users || 1245}</div>
<div className="text-sm text-slate-500">Mit Zustimmung</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-yellow-600">{stats?.pending_consents || 5}</div>
<div className="text-sm text-slate-500">Ausstehend</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-purple-600">{stats?.consent_rate?.toFixed(1) || 99.6}%</div>
<div className="text-sm text-slate-500">Zustimmungsrate</div>
</div>
</div>
{/* Tabs */}
<div className="mb-6">
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab.id
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error}
<button onClick={() => setError(null)} className="ml-4 text-red-500 hover:text-red-700">X</button>
</div>
)}
{/* Content */}
<div className="bg-white rounded-xl border border-slate-200">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Consent-Uebersicht nach Kategorie</h2>
<div className="space-y-4">
{consentSummary.map((item) => (
<div key={item.category} className="border border-slate-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium text-slate-900">{item.category}</h3>
<span className={`px-2 py-1 rounded text-xs font-medium ${
item.rate >= 90 ? 'bg-green-100 text-green-700' :
item.rate >= 50 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{item.rate}% Zustimmung
</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden mb-3">
<div
className={`h-full ${item.rate >= 90 ? 'bg-green-500' : item.rate >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${item.rate}%` }}
/>
</div>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-slate-500">Gesamt:</span>
<span className="ml-1 font-medium">{item.total}</span>
</div>
<div>
<span className="text-green-600">Akzeptiert:</span>
<span className="ml-1 font-medium">{item.accepted}</span>
</div>
<div>
<span className="text-red-600">Abgelehnt:</span>
<span className="ml-1 font-medium">{item.declined}</span>
</div>
<div>
<span className="text-yellow-600">Ausstehend:</span>
<span className="ml-1 font-medium">{item.pending}</span>
</div>
</div>
</div>
))}
</div>
{/* Export Button */}
<div className="mt-6 pt-6 border-t border-slate-200">
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium">
Consent-Report exportieren (CSV)
</button>
</div>
</div>
)}
{/* Documents Tab */}
{activeTab === 'documents' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Dokumenten-Einwilligungen</h2>
<div className="flex gap-2">
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="">Alle Dokumente</option>
<option value="terms">AGB</option>
<option value="privacy">Datenschutz</option>
<option value="cookies">Cookies</option>
</select>
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="withdrawn">Widerrufen</option>
</select>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Nutzer-ID</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Dokument</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Version</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Status</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Datum</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Quelle</th>
</tr>
</thead>
<tbody>
{/* Sample data - in production, this comes from API */}
{[
{ id: 'usr_123', doc: 'AGB', version: 'v2.1.0', status: 'active', date: '2024-12-15', source: 'Website' },
{ id: 'usr_124', doc: 'Datenschutz', version: 'v3.0.0', status: 'active', date: '2024-12-15', source: 'App' },
{ id: 'usr_125', doc: 'AGB', version: 'v2.1.0', status: 'withdrawn', date: '2024-12-14', source: 'PWA' },
].map((consent, idx) => (
<tr key={idx} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4 font-mono text-sm">{consent.id}</td>
<td className="py-3 px-4">{consent.doc}</td>
<td className="py-3 px-4 text-sm text-slate-500">{consent.version}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${
consent.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{consent.status === 'active' ? 'Aktiv' : 'Widerrufen'}
</span>
</td>
<td className="py-3 px-4 text-sm text-slate-500">{consent.date}</td>
<td className="py-3 px-4">
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">{consent.source}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Cookies Tab */}
{activeTab === 'cookies' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Cookie-Einwilligungen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ name: 'Notwendige Cookies', key: 'necessary', mandatory: true, rate: 100, description: 'Erforderlich fuer Grundfunktionen' },
{ name: 'Funktionale Cookies', key: 'functional', mandatory: false, rate: 82.3, description: 'Verbesserte Nutzererfahrung' },
{ name: 'Analyse Cookies', key: 'analytics', mandatory: false, rate: 71.4, description: 'Anonyme Nutzungsstatistiken' },
{ name: 'Marketing Cookies', key: 'marketing', mandatory: false, rate: 36.5, description: 'Personalisierte Werbung' },
].map((category) => (
<div key={category.key} className="border border-slate-200 rounded-xl p-5">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-slate-900">{category.name}</h3>
<p className="text-sm text-slate-500">{category.description}</p>
</div>
{category.mandatory && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-medium">Pflicht</span>
)}
</div>
<div className="flex items-center gap-4">
<div className="flex-grow h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${category.rate >= 80 ? 'bg-green-500' : category.rate >= 50 ? 'bg-yellow-500' : 'bg-orange-500'}`}
style={{ width: `${category.rate}%` }}
/>
</div>
<span className="text-lg font-bold text-slate-900">{category.rate}%</span>
</div>
</div>
))}
</div>
<div className="mt-6 p-4 bg-slate-50 rounded-lg">
<h4 className="font-medium text-slate-900 mb-2">Cookie-Banner Einstellungen</h4>
<p className="text-sm text-slate-600">
Das Cookie-Banner wird auf allen Plattformen (Website, App, PWA) einheitlich angezeigt.
Nutzer koennen ihre Praeferenzen jederzeit in den Einstellungen aendern.
</p>
</div>
</div>
)}
{/* Marketing Tab */}
{activeTab === 'marketing' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Marketing-Einwilligungen</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
{[
{ name: 'E-Mail Newsletter', rate: 25.0, total: 1250, subscribed: 312 },
{ name: 'Push-Benachrichtigungen', rate: 45.2, total: 1250, subscribed: 565 },
{ name: 'Personalisierte Werbung', rate: 18.5, total: 1250, subscribed: 231 },
].map((channel) => (
<div key={channel.name} className="bg-white border border-slate-200 rounded-xl p-5">
<h3 className="font-semibold text-slate-900 mb-2">{channel.name}</h3>
<div className="text-3xl font-bold text-purple-600 mb-1">{channel.rate}%</div>
<div className="text-sm text-slate-500">{channel.subscribed} von {channel.total} Nutzern</div>
<div className="mt-4 h-2 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-purple-500" style={{ width: `${channel.rate}%` }} />
</div>
</div>
))}
</div>
<div className="border border-slate-200 rounded-lg p-4">
<h4 className="font-medium text-slate-900 mb-3">Opt-Out Anfragen (letzte 30 Tage)</h4>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xl font-bold text-slate-900">23</div>
<div className="text-xs text-slate-500">Newsletter</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xl font-bold text-slate-900">45</div>
<div className="text-xs text-slate-500">Push</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xl font-bold text-slate-900">12</div>
<div className="text-xs text-slate-500">Werbung</div>
</div>
</div>
</div>
</div>
)}
{/* Audit Tab */}
{activeTab === 'audit' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Consent Audit-Trail</h2>
<div className="flex gap-2">
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="">Alle Aktionen</option>
<option value="consent_given">Zustimmung erteilt</option>
<option value="consent_withdrawn">Zustimmung widerrufen</option>
<option value="cookie_consent_updated">Cookie aktualisiert</option>
</select>
<button
onClick={loadAuditLog}
className="px-3 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm hover:bg-slate-200"
>
Aktualisieren
</button>
</div>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Audit-Log...</div>
) : (
<div className="space-y-3">
{(auditLog.length > 0 ? auditLog : [
// Sample data
{ id: '1', user_id: 'usr_123', action: 'consent_given', entity_type: 'document', entity_id: 'doc_agb', details: {}, ip_address: '192.168.1.1', created_at: '2024-12-15T10:30:00Z' },
{ id: '2', user_id: 'usr_124', action: 'cookie_consent_updated', entity_type: 'cookie', entity_id: 'analytics', details: {}, ip_address: '192.168.1.2', created_at: '2024-12-15T10:25:00Z' },
{ id: '3', user_id: 'usr_125', action: 'consent_withdrawn', entity_type: 'document', entity_id: 'doc_newsletter', details: {}, ip_address: '192.168.1.3', created_at: '2024-12-15T10:20:00Z' },
]).map((entry) => (
<div key={entry.id} className="border border-slate-200 rounded-lg p-4 hover:bg-slate-50">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${getActionColor(entry.action)}`}>
{getActionLabel(entry.action)}
</span>
<span className="font-mono text-sm text-slate-600">{entry.user_id}</span>
</div>
<span className="text-sm text-slate-400">
{new Date(entry.created_at).toLocaleString('de-DE')}
</span>
</div>
<div className="mt-2 text-sm text-slate-500">
<span className="text-slate-400">Entity:</span> {entry.entity_type} / {entry.entity_id}
<span className="mx-2 text-slate-300">|</span>
<span className="text-slate-400">IP:</span> {entry.ip_address}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* GDPR Notice */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-purple-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-semibold text-purple-900">DSGVO-Hinweis</h4>
<p className="text-sm text-purple-800 mt-1">
Alle Einwilligungen werden revisionssicher gespeichert und koennen jederzeit nachgewiesen werden.
Nutzer koennen ihre Einwilligungen gemaess Art. 7 Abs. 3 DSGVO jederzeit widerrufen.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,583 +0,0 @@
'use client'
/**
* Evidence Management Page
*
* Features:
* - List evidence by control
* - File upload
* - URL/Link adding
* - Evidence status tracking
*/
import { useState, useEffect, useRef, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
interface Evidence {
id: string
control_id: string
evidence_type: string
title: string
description: string
artifact_path: string | null
artifact_url: string | null
artifact_hash: string | null
file_size_bytes: number | null
mime_type: string | null
status: string
source: string
ci_job_id: string | null
valid_from: string
valid_until: string | null
collected_at: string
}
interface Control {
id: string
control_id: string
title: string
}
const EVIDENCE_TYPES = [
{ value: 'scan_report', label: 'Scan Report', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ value: 'policy_document', label: 'Policy Dokument', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ value: 'config_snapshot', label: 'Config Snapshot', icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4' },
{ value: 'test_result', label: 'Test Ergebnis', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
{ value: 'screenshot', label: 'Screenshot', icon: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z' },
{ value: 'external_link', label: 'Externer Link', icon: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14' },
{ value: 'manual_upload', label: 'Manueller Upload', icon: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12' },
]
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
valid: { bg: 'bg-green-100', text: 'text-green-700', label: 'Gueltig' },
expired: { bg: 'bg-red-100', text: 'text-red-700', label: 'Abgelaufen' },
pending: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Ausstehend' },
failed: { bg: 'bg-red-100', text: 'text-red-700', label: 'Fehlgeschlagen' },
}
function EvidencePageContent({ initialControlId }: { initialControlId: string | null }) {
const [evidence, setEvidence] = useState<Evidence[]>([])
const [controls, setControls] = useState<Control[]>([])
const [loading, setLoading] = useState(true)
const [filterControlId, setFilterControlId] = useState(initialControlId || '')
const [filterType, setFilterType] = useState('')
const [uploadModalOpen, setUploadModalOpen] = useState(false)
const [linkModalOpen, setLinkModalOpen] = useState(false)
const [uploading, setUploading] = useState(false)
const [newEvidence, setNewEvidence] = useState({
control_id: initialControlId || '',
evidence_type: 'manual_upload',
title: '',
description: '',
artifact_url: '',
})
const fileInputRef = useRef<HTMLInputElement>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
useEffect(() => {
loadData()
}, [filterControlId, filterType])
const loadData = async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterControlId) params.append('control_id', filterControlId)
if (filterType) params.append('evidence_type', filterType)
const [evidenceRes, controlsRes] = await Promise.all([
fetch(`/api/admin/compliance/evidence?${params}`),
fetch(`/api/admin/compliance/controls`),
])
if (evidenceRes.ok) {
const data = await evidenceRes.json()
setEvidence(data.evidence || [])
}
if (controlsRes.ok) {
const data = await controlsRes.json()
setControls(data.controls || [])
}
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setLoading(false)
}
}
const handleFileUpload = async () => {
if (!selectedFile || !newEvidence.control_id || !newEvidence.title) {
alert('Bitte alle Pflichtfelder ausfuellen')
return
}
setUploading(true)
try {
const formData = new FormData()
formData.append('file', selectedFile)
const params = new URLSearchParams({
control_id: newEvidence.control_id,
evidence_type: newEvidence.evidence_type,
title: newEvidence.title,
})
if (newEvidence.description) {
params.append('description', newEvidence.description)
}
const res = await fetch(`/api/admin/compliance/evidence/upload?${params}`, {
method: 'POST',
body: formData,
})
if (res.ok) {
setUploadModalOpen(false)
resetForm()
loadData()
} else {
const error = await res.text()
alert(`Upload fehlgeschlagen: ${error}`)
}
} catch (error) {
console.error('Upload failed:', error)
alert('Upload fehlgeschlagen')
} finally {
setUploading(false)
}
}
const handleLinkSubmit = async () => {
if (!newEvidence.control_id || !newEvidence.title || !newEvidence.artifact_url) {
alert('Bitte alle Pflichtfelder ausfuellen')
return
}
setUploading(true)
try {
const res = await fetch(`/api/admin/compliance/evidence`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
control_id: newEvidence.control_id,
evidence_type: 'external_link',
title: newEvidence.title,
description: newEvidence.description,
artifact_url: newEvidence.artifact_url,
source: 'manual',
}),
})
if (res.ok) {
setLinkModalOpen(false)
resetForm()
loadData()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Failed to add link:', error)
alert('Fehler beim Hinzufuegen')
} finally {
setUploading(false)
}
}
const resetForm = () => {
setNewEvidence({
control_id: filterControlId || '',
evidence_type: 'manual_upload',
title: '',
description: '',
artifact_url: '',
})
setSelectedFile(null)
}
const formatFileSize = (bytes: number | null) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const getControlTitle = (controlUuid: string) => {
const control = controls.find((c) => c.id === controlUuid)
return control?.control_id || controlUuid
}
// Statistics
const stats = {
total: evidence.length,
valid: evidence.filter(e => e.status === 'valid').length,
expired: evidence.filter(e => e.status === 'expired').length,
pending: evidence.filter(e => e.status === 'pending').length,
automated: evidence.filter(e => e.source === 'ci_pipeline').length,
}
return (
<div className="min-h-screen bg-slate-50 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Evidence Management</h1>
<p className="text-slate-600">Nachweise & Artefakte</p>
</div>
<Link
href="/compliance/hub"
className="flex items-center gap-2 text-slate-600 hover:text-slate-800"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Compliance Hub
</Link>
</div>
{/* Page Purpose */}
<PagePurpose
title="Evidence Management"
purpose="Verwalten Sie alle Nachweise und Artefakte, die die Einhaltung von Compliance-Anforderungen belegen. Jeder Control kann mit mehreren Nachweisen verknuepft werden - von automatischen Scan-Reports bis zu manuellen Dokumenten."
audience={['CISO', 'DSB', 'Compliance Officer', 'Auditoren']}
gdprArticles={['Art. 5(2) (Rechenschaftspflicht)', 'Art. 30 (Verzeichnis von Verarbeitungstaetigkeiten)']}
architecture={{
services: ['Python Backend (FastAPI)', 'compliance_evidence Modul'],
databases: ['PostgreSQL (compliance_evidence Table)', 'MinIO (Datei-Storage)'],
}}
relatedPages={[
{ name: 'Controls', href: '/compliance/controls', description: 'Control-Katalog verwalten' },
{ name: 'Audit Checklist', href: '/compliance/audit-checklist', description: 'Anforderungen pruefen' },
]}
/>
{/* Statistics Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 border border-slate-200">
<p className="text-sm text-slate-500">Gesamt</p>
<p className="text-2xl font-bold text-slate-900">{stats.total}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-green-200">
<p className="text-sm text-green-600">Gueltig</p>
<p className="text-2xl font-bold text-green-700">{stats.valid}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-red-200">
<p className="text-sm text-red-600">Abgelaufen</p>
<p className="text-2xl font-bold text-red-700">{stats.expired}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-yellow-200">
<p className="text-sm text-yellow-600">Ausstehend</p>
<p className="text-2xl font-bold text-yellow-700">{stats.pending}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-blue-200">
<p className="text-sm text-blue-600">CI/CD</p>
<p className="text-2xl font-bold text-blue-700">{stats.automated}</p>
</div>
</div>
{/* Actions & Filters */}
<div className="bg-white rounded-xl shadow-sm border p-4 mb-6">
<div className="flex flex-wrap items-center gap-4">
<select
value={filterControlId}
onChange={(e) => setFilterControlId(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Controls</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
</select>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Typen</option>
{EVIDENCE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<div className="flex-1" />
<button
onClick={() => { resetForm(); setLinkModalOpen(true) }}
className="px-4 py-2 border border-primary-600 text-primary-600 rounded-lg hover:bg-primary-50"
>
Link hinzufuegen
</button>
<button
onClick={() => { resetForm(); setUploadModalOpen(true) }}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Datei hochladen
</button>
</div>
</div>
{/* Evidence List */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : evidence.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-slate-500 mb-4">Keine Nachweise gefunden</p>
<button
onClick={() => setUploadModalOpen(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Ersten Nachweis hinzufuegen
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{evidence.map((ev) => {
const statusStyle = STATUS_STYLES[ev.status] || STATUS_STYLES.pending
return (
<div key={ev.id} className="bg-white rounded-xl shadow-sm border p-4 hover:border-primary-300 transition-colors">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={EVIDENCE_TYPES.find((t) => t.value === ev.evidence_type)?.icon || 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'} />
</svg>
</div>
<span className={`px-2 py-0.5 text-xs rounded-full ${statusStyle.bg} ${statusStyle.text}`}>
{statusStyle.label}
</span>
</div>
<span className="text-xs text-slate-500 font-mono">{getControlTitle(ev.control_id)}</span>
</div>
<h4 className="font-medium text-slate-900 mb-1">{ev.title}</h4>
{ev.description && (
<p className="text-sm text-slate-500 mb-3 line-clamp-2">{ev.description}</p>
)}
<div className="flex items-center justify-between text-xs text-slate-500 pt-3 border-t">
<span>{EVIDENCE_TYPES.find(t => t.value === ev.evidence_type)?.label || ev.evidence_type}</span>
<span>{formatFileSize(ev.file_size_bytes)}</span>
</div>
{ev.artifact_url && (
<a
href={ev.artifact_url}
target="_blank"
rel="noopener noreferrer"
className="mt-3 block text-sm text-primary-600 hover:text-primary-700 truncate"
>
{ev.artifact_url}
</a>
)}
<div className="mt-3 flex items-center justify-between text-xs text-slate-400">
<span>Erfasst: {new Date(ev.collected_at).toLocaleDateString('de-DE')}</span>
{ev.source === 'ci_pipeline' && (
<span className="bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded">CI/CD</span>
)}
</div>
</div>
)
})}
</div>
)}
{/* Upload Modal */}
{uploadModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
<div className="p-6 border-b">
<h3 className="text-lg font-semibold text-slate-900">Datei hochladen</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
<select
value={newEvidence.control_id}
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Control auswaehlen...</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={newEvidence.evidence_type}
onChange={(e) => setNewEvidence({ ...newEvidence, evidence_type: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{EVIDENCE_TYPES.filter((t) => t.value !== 'external_link').map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={newEvidence.title}
onChange={(e) => setNewEvidence({ ...newEvidence, title: e.target.value })}
placeholder="z.B. Semgrep Scan Report 2026-01"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={newEvidence.description}
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Datei *</label>
<input
type="file"
ref={fileInputRef}
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
{selectedFile && (
<p className="mt-1 text-sm text-slate-500">
{selectedFile.name} ({formatFileSize(selectedFile.size)})
</p>
)}
</div>
</div>
<div className="p-6 border-t bg-slate-50 flex justify-end gap-3">
<button
onClick={() => setUploadModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={uploading}
>
Abbrechen
</button>
<button
onClick={handleFileUpload}
disabled={uploading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{uploading ? 'Hochladen...' : 'Hochladen'}
</button>
</div>
</div>
</div>
)}
{/* Link Modal */}
{linkModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
<div className="p-6 border-b">
<h3 className="text-lg font-semibold text-slate-900">Link/Quelle hinzufuegen</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
<select
value={newEvidence.control_id}
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Control auswaehlen...</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={newEvidence.title}
onChange={(e) => setNewEvidence({ ...newEvidence, title: e.target.value })}
placeholder="z.B. GitHub Branch Protection Settings"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">URL *</label>
<input
type="url"
value={newEvidence.artifact_url}
onChange={(e) => setNewEvidence({ ...newEvidence, artifact_url: e.target.value })}
placeholder="https://github.com/..."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={newEvidence.description}
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<div className="p-6 border-t bg-slate-50 flex justify-end gap-3">
<button
onClick={() => setLinkModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={uploading}
>
Abbrechen
</button>
<button
onClick={handleLinkSubmit}
disabled={uploading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{uploading ? 'Speichern...' : 'Hinzufuegen'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
function EvidencePageWithParams() {
const searchParams = useSearchParams()
const initialControlId = searchParams.get('control')
return <EvidencePageContent initialControlId={initialControlId} />
}
export default function EvidencePage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-slate-50 p-6 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
}>
<EvidencePageWithParams />
</Suspense>
)
}

View File

@@ -1,545 +0,0 @@
'use client'
/**
* Compliance Hub - Central Compliance Management Dashboard
*
* Features:
* - Compliance Score Overview
* - Quick Access to all compliance modules
* - 474 Control-Mappings with statistics
* - Haupt-/Nebenabweichungen (Major/Minor findings)
* - Regulations overview
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
// Types
interface DashboardData {
compliance_score: number
total_regulations: number
total_requirements: number
total_controls: number
controls_by_status: Record<string, number>
controls_by_domain: Record<string, Record<string, number>>
total_evidence: number
evidence_by_status: Record<string, number>
total_risks: number
risks_by_level: Record<string, number>
}
interface Regulation {
id: string
code: string
name: string
full_name: string
regulation_type: string
effective_date: string | null
description: string
requirement_count: number
}
interface ControlMapping {
id: string
control_id: string
requirement_id: string
control_title: string
requirement_title: string
regulation_code: string
mapping_strength: string
}
interface MappingsData {
mappings: ControlMapping[]
total: number
by_regulation: Record<string, number>
}
interface FindingsData {
major_count: number
minor_count: number
ofi_count: number
total: number
open_majors: number
open_minors: number
}
const DOMAIN_LABELS: Record<string, string> = {
gov: 'Governance',
priv: 'Datenschutz',
iam: 'Identity & Access',
crypto: 'Kryptografie',
sdlc: 'Secure Dev',
ops: 'Operations',
ai: 'KI-spezifisch',
cra: 'Supply Chain',
aud: 'Audit',
}
export default function ComplianceHubPage() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [regulations, setRegulations] = useState<Regulation[]>([])
const [mappings, setMappings] = useState<MappingsData | null>(null)
const [findings, setFindings] = useState<FindingsData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [seeding, setSeeding] = useState(false)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
setError(null)
try {
const [dashboardRes, regulationsRes, mappingsRes, findingsRes] = await Promise.all([
fetch('/api/admin/compliance/dashboard'),
fetch('/api/admin/compliance/regulations'),
fetch('/api/admin/compliance/mappings'),
fetch('/api/admin/compliance/isms/findings/summary'),
])
if (dashboardRes.ok) {
setDashboard(await dashboardRes.json())
}
if (regulationsRes.ok) {
const data = await regulationsRes.json()
setRegulations(data.regulations || [])
}
if (mappingsRes.ok) {
const data = await mappingsRes.json()
setMappings(data)
}
if (findingsRes.ok) {
const data = await findingsRes.json()
setFindings(data)
}
} catch (err) {
console.error('Failed to load compliance data:', err)
setError('Verbindung zum Backend fehlgeschlagen')
} finally {
setLoading(false)
}
}
const seedDatabase = async () => {
setSeeding(true)
try {
const res = await fetch('/api/admin/compliance/seed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: false }),
})
if (res.ok) {
const result = await res.json()
alert(`Datenbank erfolgreich initialisiert!\n\nRegulations: ${result.counts?.regulations || 0}\nControls: ${result.counts?.controls || 0}\nRequirements: ${result.counts?.requirements || 0}`)
loadData()
} else {
const error = await res.text()
alert(`Fehler beim Seeding: ${error}`)
}
} catch (err) {
console.error('Seeding failed:', err)
alert('Fehler beim Initialisieren der Datenbank')
} finally {
setSeeding(false)
}
}
const score = dashboard?.compliance_score || 0
const scoreColor = score >= 80 ? 'text-green-600' : score >= 60 ? 'text-yellow-600' : 'text-red-600'
const scoreBgColor = score >= 80 ? 'bg-green-500' : score >= 60 ? 'bg-yellow-500' : 'bg-red-500'
return (
<div className="space-y-6">
<PagePurpose
title="Compliance Hub"
purpose="Zentrale Verwaltung aller Compliance-Anforderungen nach DSGVO, AI Act, BSI TR-03161 und weiteren Regulierungen. Hier sehen Sie den aktuellen Compliance-Stand und haben Zugriff auf alle Module."
audience={['DSB', 'Compliance Officer', 'Auditor', 'Entwickler']}
gdprArticles={['Art. 5 (Grundsaetze)', 'Art. 24 (Verantwortung)', 'Art. 32 (Sicherheit)']}
architecture={{
services: ['Python Backend', 'PostgreSQL'],
databases: ['compliance_*'],
}}
relatedPages={[
{ name: 'Audit Checkliste', href: '/compliance/audit-checklist', description: '476 Anforderungen pruefen' },
{ name: 'Controls', href: '/compliance/controls', description: 'Technische Massnahmen' },
{ name: 'Risiken', href: '/compliance/risks', description: 'Risikoregister & Matrix' },
]}
/>
{/* Error Banner */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<svg className="w-5 h-5 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-red-700">{error}</span>
<button onClick={loadData} className="ml-auto text-red-600 hover:text-red-800 text-sm font-medium">
Erneut versuchen
</button>
</div>
)}
{/* Seed Button if no data */}
{!loading && (dashboard?.total_controls || 0) === 0 && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-yellow-800">Keine Compliance-Daten vorhanden</p>
<p className="text-sm text-yellow-700">Initialisieren Sie die Datenbank mit den Seed-Daten.</p>
</div>
<button
onClick={seedDatabase}
disabled={seeding}
className="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 disabled:opacity-50"
>
{seeding ? 'Initialisiere...' : 'Datenbank initialisieren'}
</button>
</div>
</div>
)}
{/* Quick Actions - Always visible at top */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schnellzugriff</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
<Link
href="/compliance/audit-checklist"
className="p-4 rounded-lg border border-slate-200 hover:border-purple-500 hover:bg-purple-50 transition-colors text-center"
>
<div className="text-purple-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Audit Checkliste</p>
<p className="text-xs text-slate-500 mt-1">{dashboard?.total_requirements || '...'} Anforderungen</p>
</Link>
<Link
href="/compliance/controls"
className="p-4 rounded-lg border border-slate-200 hover:border-green-500 hover:bg-green-50 transition-colors text-center"
>
<div className="text-green-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Controls</p>
<p className="text-xs text-slate-500 mt-1">{dashboard?.total_controls || '...'} Massnahmen</p>
</Link>
<Link
href="/compliance/evidence"
className="p-4 rounded-lg border border-slate-200 hover:border-blue-500 hover:bg-blue-50 transition-colors text-center"
>
<div className="text-blue-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Evidence</p>
<p className="text-xs text-slate-500 mt-1">Nachweise</p>
</Link>
<Link
href="/compliance/risks"
className="p-4 rounded-lg border border-slate-200 hover:border-red-500 hover:bg-red-50 transition-colors text-center"
>
<div className="text-red-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Risk Matrix</p>
<p className="text-xs text-slate-500 mt-1">5x5 Risiken</p>
</Link>
<Link
href="/compliance/modules"
className="p-4 rounded-lg border border-slate-200 hover:border-pink-500 hover:bg-pink-50 transition-colors text-center"
>
<div className="text-pink-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Service Registry</p>
<p className="text-xs text-slate-500 mt-1">Module</p>
</Link>
<Link
href="/compliance/audit-report"
className="p-4 rounded-lg border border-slate-200 hover:border-orange-500 hover:bg-orange-50 transition-colors text-center"
>
<div className="text-orange-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Audit Report</p>
<p className="text-xs text-slate-500 mt-1">PDF Export</p>
</Link>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600" />
</div>
) : (
<>
{/* Score and Stats Row */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
{/* Score Card */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Compliance Score</h3>
<div className={`text-5xl font-bold ${scoreColor}`}>
{score.toFixed(0)}%
</div>
<div className="mt-4 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${scoreBgColor}`}
style={{ width: `${score}%` }}
/>
</div>
<p className="mt-2 text-sm text-slate-500">
{dashboard?.controls_by_status?.pass || 0} von {dashboard?.total_controls || 0} Controls bestanden
</p>
</div>
{/* Stats Cards */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Verordnungen</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_regulations || 0}</p>
</div>
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.total_requirements || 0} Anforderungen</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Controls</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_controls || 0}</p>
</div>
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.controls_by_status?.pass || 0} bestanden</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Nachweise</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_evidence || 0}</p>
</div>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.evidence_by_status?.valid || 0} aktiv</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Risiken</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_risks || 0}</p>
</div>
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">
{(dashboard?.risks_by_level?.high || 0) + (dashboard?.risks_by_level?.critical || 0)} kritisch
</p>
</div>
</div>
{/* Control-Mappings & Findings Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 474 Control-Mappings Card */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Control-Mappings</h3>
<Link href="/compliance/controls" className="text-sm text-purple-600 hover:text-purple-700">
Alle anzeigen
</Link>
</div>
<div className="flex items-center gap-6 mb-4">
<div>
<p className="text-4xl font-bold text-purple-600">{mappings?.total || 474}</p>
<p className="text-sm text-slate-500">Mappings gesamt</p>
</div>
<div className="flex-1 h-16 bg-slate-50 rounded-lg p-3">
<p className="text-xs text-slate-500 mb-1">Nach Verordnung</p>
<div className="flex gap-1 flex-wrap">
{mappings?.by_regulation && Object.entries(mappings.by_regulation).slice(0, 5).map(([reg, count]) => (
<span key={reg} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
{reg}: {count}
</span>
))}
{!mappings?.by_regulation && (
<>
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">GDPR: 180</span>
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">AI Act: 95</span>
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">BSI: 120</span>
<span className="px-2 py-0.5 bg-orange-100 text-orange-700 rounded text-xs">CRA: 79</span>
</>
)}
</div>
</div>
</div>
<p className="text-sm text-slate-600">
Automatisch generierte Verknuepfungen zwischen {dashboard?.total_controls || 44} Controls
und {dashboard?.total_requirements || 558} Anforderungen aus {dashboard?.total_regulations || 19} Verordnungen.
</p>
</div>
{/* Haupt-/Nebenabweichungen Card */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Audit Findings</h3>
<Link href="/compliance/audit-checklist" className="text-sm text-purple-600 hover:text-purple-700">
Audit Checkliste
</Link>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-sm font-medium text-red-800">Hauptabweichungen</span>
</div>
<p className="text-3xl font-bold text-red-600">{findings?.open_majors || 0}</p>
<p className="text-xs text-red-600">offen (blockiert Zertifizierung)</p>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
<span className="text-sm font-medium text-yellow-800">Nebenabweichungen</span>
</div>
<p className="text-3xl font-bold text-yellow-600">{findings?.open_minors || 0}</p>
<p className="text-xs text-yellow-600">offen (erfordert CAPA)</p>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">
Gesamt: {findings?.total || 0} Findings ({findings?.major_count || 0} Major, {findings?.minor_count || 0} Minor, {findings?.ofi_count || 0} OFI)
</span>
{(findings?.open_majors || 0) === 0 ? (
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">
Zertifizierung moeglich
</span>
) : (
<span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
Zertifizierung blockiert
</span>
)}
</div>
</div>
</div>
{/* Domain Chart - Full Width */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Controls nach Domain</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(dashboard?.controls_by_domain || {}).map(([domain, stats]) => {
const total = stats.total || 0
const pass = stats.pass || 0
const partial = stats.partial || 0
const passPercent = total > 0 ? ((pass + partial * 0.5) / total) * 100 : 0
return (
<div key={domain} className="p-3 rounded-lg bg-slate-50">
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-slate-700">
{DOMAIN_LABELS[domain] || domain.toUpperCase()}
</span>
<span className="text-slate-500">
{pass}/{total} ({passPercent.toFixed(0)}%)
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden flex">
<div className="bg-green-500 h-full" style={{ width: `${(pass / total) * 100}%` }} />
<div className="bg-yellow-500 h-full" style={{ width: `${(partial / total) * 100}%` }} />
</div>
</div>
)
})}
</div>
</div>
{/* Regulations Table */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Verordnungen & Standards ({regulations.length})</h3>
<button onClick={loadData} className="text-sm text-purple-600 hover:text-purple-700">
Aktualisieren
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Code</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Anforderungen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{regulations.slice(0, 15).map((reg) => (
<tr key={reg.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-purple-600">{reg.code}</span>
</td>
<td className="px-4 py-3">
<p className="font-medium text-slate-900">{reg.name}</p>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${
reg.regulation_type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' :
reg.regulation_type === 'eu_directive' ? 'bg-purple-100 text-purple-700' :
reg.regulation_type === 'bsi_standard' ? 'bg-green-100 text-green-700' :
'bg-slate-100 text-slate-700'
}`}>
{reg.regulation_type === 'eu_regulation' ? 'EU-VO' :
reg.regulation_type === 'eu_directive' ? 'EU-RL' :
reg.regulation_type === 'bsi_standard' ? 'BSI' :
reg.regulation_type === 'de_law' ? 'DE' : reg.regulation_type}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-medium">{reg.requirement_count}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
)
}

View File

@@ -1,511 +0,0 @@
'use client'
/**
* Loeschfristen - Data Retention Management
*
* Art. 17 DSGVO - Recht auf Loeschung
* Art. 5 Abs. 1 lit. e DSGVO - Speicherbegrenzung
*
* Verwaltet:
* - Aufbewahrungsfristen
* - Consent-Deadlines
* - Automatische Loeschungen
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
const API_BASE = '/api/admin/consent'
interface RetentionPolicy {
id: string
dataCategory: string
retentionPeriod: string
legalBasis: string
autoDelete: boolean
lastRun?: string
nextRun?: string
itemsToDelete?: number
}
interface ConsentDeadline {
id: string
userId: string
documentName: string
versionNumber: string
deadlineAt: string
reminderCount: number
daysRemaining: number
status: 'pending' | 'overdue' | 'completed'
}
interface DeletionJob {
id: string
dataCategory: string
scheduledAt: string
status: 'scheduled' | 'running' | 'completed' | 'failed'
itemsProcessed: number
itemsTotal: number
completedAt?: string
}
export default function LoeschfristenPage() {
const [activeTab, setActiveTab] = useState<'policies' | 'deadlines' | 'jobs' | 'manual'>('policies')
const [loading, setLoading] = useState(false)
const [processing, setProcessing] = useState(false)
// Mock data - in production, this comes from API
const retentionPolicies: RetentionPolicy[] = [
{
id: 'pol_1',
dataCategory: 'Nutzerkonten (inaktiv)',
retentionPeriod: '3 Jahre nach letzter Aktivitaet',
legalBasis: 'Art. 5 Abs. 1 lit. e DSGVO',
autoDelete: true,
lastRun: '2024-12-01',
nextRun: '2025-01-01',
itemsToDelete: 23
},
{
id: 'pol_2',
dataCategory: 'Consent-Nachweise',
retentionPeriod: '6 Jahre nach Widerruf',
legalBasis: 'Nachweispflicht',
autoDelete: true,
lastRun: '2024-12-01',
nextRun: '2025-01-01',
itemsToDelete: 0
},
{
id: 'pol_3',
dataCategory: 'System-Logs',
retentionPeriod: '90 Tage',
legalBasis: 'Berechtigtes Interesse (IT-Sicherheit)',
autoDelete: true,
lastRun: '2024-12-14',
nextRun: '2024-12-15',
itemsToDelete: 15420
},
{
id: 'pol_4',
dataCategory: 'Security-Logs',
retentionPeriod: '2 Jahre',
legalBasis: 'Berechtigtes Interesse (Sicherheit)',
autoDelete: true,
lastRun: '2024-12-01',
nextRun: '2025-01-01',
itemsToDelete: 0
},
{
id: 'pol_5',
dataCategory: 'Lernfortschrittsdaten',
retentionPeriod: 'Ende Schuljahr + 1 Jahr',
legalBasis: 'Vertragserfuellung',
autoDelete: false,
itemsToDelete: 45
},
{
id: 'pol_6',
dataCategory: 'KI-Verarbeitungsdaten',
retentionPeriod: 'Sofortige Loeschung',
legalBasis: 'Datenminimierung',
autoDelete: true,
lastRun: '2024-12-15',
nextRun: 'Kontinuierlich',
itemsToDelete: 0
},
]
const consentDeadlines: ConsentDeadline[] = [
{ id: 'dl_1', userId: 'usr_456', documentName: 'AGB', versionNumber: 'v2.1.0', deadlineAt: '2025-01-15', reminderCount: 2, daysRemaining: 20, status: 'pending' },
{ id: 'dl_2', userId: 'usr_789', documentName: 'Datenschutz', versionNumber: 'v3.0.0', deadlineAt: '2024-12-28', reminderCount: 3, daysRemaining: 3, status: 'pending' },
{ id: 'dl_3', userId: 'usr_012', documentName: 'AGB', versionNumber: 'v2.1.0', deadlineAt: '2024-12-10', reminderCount: 4, daysRemaining: -5, status: 'overdue' },
]
const deletionJobs: DeletionJob[] = [
{ id: 'job_1', dataCategory: 'System-Logs', scheduledAt: '2024-12-14T02:00:00', status: 'completed', itemsProcessed: 12500, itemsTotal: 12500, completedAt: '2024-12-14T02:15:00' },
{ id: 'job_2', dataCategory: 'Inaktive Sessions', scheduledAt: '2024-12-14T03:00:00', status: 'completed', itemsProcessed: 450, itemsTotal: 450, completedAt: '2024-12-14T03:02:00' },
{ id: 'job_3', dataCategory: 'System-Logs', scheduledAt: '2024-12-15T02:00:00', status: 'scheduled', itemsProcessed: 0, itemsTotal: 15420 },
]
async function triggerDeadlineProcessing() {
setProcessing(true)
try {
const token = localStorage.getItem('bp_admin_token')
const res = await fetch(`${API_BASE}/deadlines`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
})
if (res.ok) {
alert('Deadline-Verarbeitung gestartet')
} else {
alert('Fehler bei der Verarbeitung')
}
} catch {
alert('Verbindungsfehler')
} finally {
setProcessing(false)
}
}
const tabs = [
{ id: 'policies', label: 'Aufbewahrungsfristen' },
{ id: 'deadlines', label: 'Consent-Deadlines' },
{ id: 'jobs', label: 'Loeschjobs' },
{ id: 'manual', label: 'Manuelle Loeschung' },
]
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Abgeschlossen</span>
case 'running':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Laeuft</span>
case 'scheduled':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Geplant</span>
case 'failed':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Fehlgeschlagen</span>
case 'pending':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Ausstehend</span>
case 'overdue':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Ueberfaellig</span>
default:
return null
}
}
return (
<div>
<PagePurpose
title="Loeschfristen & Datenaufbewahrung"
purpose="Verwaltung von Aufbewahrungsfristen, automatischen Loeschungen und Consent-Deadlines gemaess DSGVO Art. 5 (Speicherbegrenzung) und Art. 17 (Recht auf Loeschung)."
audience={['DSB', 'IT-Admin', 'Compliance Officer']}
gdprArticles={['Art. 5 Abs. 1 lit. e (Speicherbegrenzung)', 'Art. 17 (Recht auf Loeschung)']}
architecture={{
services: ['consent-service (Go)', 'cron-jobs'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'VVT', href: '/compliance/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'DSR', href: '/compliance/dsr', description: 'Loeschanfragen' },
{ name: 'Einwilligungen', href: '/compliance/einwilligungen', description: 'Consent-Uebersicht' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{retentionPolicies.length}</div>
<div className="text-sm text-slate-500">Aufbewahrungsrichtlinien</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-yellow-600">
{consentDeadlines.filter(d => d.status === 'pending').length}
</div>
<div className="text-sm text-slate-500">Offene Deadlines</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-red-600">
{consentDeadlines.filter(d => d.status === 'overdue').length}
</div>
<div className="text-sm text-slate-500">Ueberfaellige</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-purple-600">
{retentionPolicies.reduce((sum, p) => sum + (p.itemsToDelete || 0), 0).toLocaleString()}
</div>
<div className="text-sm text-slate-500">Zur Loeschung vorgemerkt</div>
</div>
</div>
{/* Tabs */}
<div className="mb-6">
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab.id
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200">
{/* Policies Tab */}
{activeTab === 'policies' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Aufbewahrungsfristen</h2>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium">
+ Neue Richtlinie
</button>
</div>
<div className="space-y-4">
{retentionPolicies.map((policy) => (
<div key={policy.id} className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-grow">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold text-slate-900">{policy.dataCategory}</h3>
{policy.autoDelete ? (
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Auto-Loeschung</span>
) : (
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">Manuell</span>
)}
{(policy.itemsToDelete || 0) > 0 && (
<span className="px-2 py-0.5 bg-orange-100 text-orange-700 rounded text-xs">
{policy.itemsToDelete} zur Loeschung
</span>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-slate-500">Frist:</span>
<span className="ml-1 font-medium text-slate-700">{policy.retentionPeriod}</span>
</div>
<div>
<span className="text-slate-500">Rechtsgrundlage:</span>
<span className="ml-1 text-slate-600">{policy.legalBasis}</span>
</div>
{policy.lastRun && (
<div>
<span className="text-slate-500">Letzter Lauf:</span>
<span className="ml-1 text-slate-600">{policy.lastRun}</span>
</div>
)}
{policy.nextRun && (
<div>
<span className="text-slate-500">Naechster Lauf:</span>
<span className="ml-1 text-slate-600">{policy.nextRun}</span>
</div>
)}
</div>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg">
Bearbeiten
</button>
{(policy.itemsToDelete || 0) > 0 && (
<button className="px-3 py-1.5 text-sm text-white bg-red-600 hover:bg-red-700 rounded-lg">
Jetzt loeschen
</button>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Deadlines Tab */}
{activeTab === 'deadlines' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Consent-Deadlines</h2>
<button
onClick={triggerDeadlineProcessing}
disabled={processing}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium disabled:opacity-50"
>
{processing ? 'Verarbeite...' : 'Deadlines verarbeiten'}
</button>
</div>
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
Nutzer haben 30 Tage Zeit, neue Pflichtdokumente zu akzeptieren.
Nach Ablauf wird der Account gesperrt, bis die Zustimmung erteilt wird.
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Nutzer</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Dokument</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Deadline</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Erinnerungen</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Status</th>
<th className="text-right py-3 px-4 text-sm font-medium text-slate-500">Aktionen</th>
</tr>
</thead>
<tbody>
{consentDeadlines.map((deadline) => (
<tr key={deadline.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4 font-mono text-sm">{deadline.userId}</td>
<td className="py-3 px-4">
<div>{deadline.documentName}</div>
<div className="text-xs text-slate-500">{deadline.versionNumber}</div>
</td>
<td className="py-3 px-4">
<div>{deadline.deadlineAt}</div>
<div className={`text-xs ${deadline.daysRemaining < 0 ? 'text-red-600' : deadline.daysRemaining <= 7 ? 'text-orange-600' : 'text-slate-500'}`}>
{deadline.daysRemaining < 0
? `${Math.abs(deadline.daysRemaining)} Tage ueberfaellig`
: `${deadline.daysRemaining} Tage verbleibend`}
</div>
</td>
<td className="py-3 px-4">
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
{deadline.reminderCount} gesendet
</span>
</td>
<td className="py-3 px-4">{getStatusBadge(deadline.status)}</td>
<td className="py-3 px-4 text-right">
<button className="text-purple-600 hover:text-purple-700 text-sm font-medium mr-3">
Erinnerung senden
</button>
{deadline.status === 'overdue' && (
<button className="text-red-600 hover:text-red-700 text-sm font-medium">
Account sperren
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Jobs Tab */}
{activeTab === 'jobs' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Loeschjobs</h2>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium">
+ Neuer Job
</button>
</div>
<div className="space-y-4">
{deletionJobs.map((job) => (
<div key={job.id} className="border border-slate-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h3 className="font-medium text-slate-900">{job.dataCategory}</h3>
{getStatusBadge(job.status)}
</div>
<span className="text-sm text-slate-500">
Geplant: {new Date(job.scheduledAt).toLocaleString('de-DE')}
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex-grow h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${job.status === 'completed' ? 'bg-green-500' : job.status === 'running' ? 'bg-blue-500' : 'bg-slate-300'}`}
style={{ width: `${job.itemsTotal > 0 ? (job.itemsProcessed / job.itemsTotal) * 100 : 0}%` }}
/>
</div>
<span className="text-sm text-slate-600 whitespace-nowrap">
{job.itemsProcessed.toLocaleString()} / {job.itemsTotal.toLocaleString()}
</span>
</div>
{job.completedAt && (
<div className="mt-2 text-xs text-slate-500">
Abgeschlossen: {new Date(job.completedAt).toLocaleString('de-DE')}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Manual Tab */}
{activeTab === 'manual' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Manuelle Loeschung</h2>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div className="flex gap-3">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h4 className="font-semibold text-red-900">Achtung: Manuelle Loeschung</h4>
<p className="text-sm text-red-800 mt-1">
Manuelle Loeschungen sind unwiderruflich. Stellen Sie sicher, dass keine gesetzlichen
Aufbewahrungsfristen verletzt werden und alle notwendigen Backups erstellt wurden.
</p>
</div>
</div>
</div>
<div className="space-y-6">
<div className="border border-slate-200 rounded-lg p-4">
<h3 className="font-medium text-slate-900 mb-3">Nutzer-Daten loeschen</h3>
<div className="flex gap-3">
<input
type="text"
placeholder="Nutzer-ID eingeben..."
className="flex-grow px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<button className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium">
Daten loeschen
</button>
</div>
<p className="text-xs text-slate-500 mt-2">
Loescht alle personenbezogenen Daten eines Nutzers (Art. 17 DSGVO)
</p>
</div>
<div className="border border-slate-200 rounded-lg p-4">
<h3 className="font-medium text-slate-900 mb-3">Alte Logs bereinigen</h3>
<div className="flex gap-3">
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="system">System-Logs</option>
<option value="audit">Audit-Logs</option>
<option value="access">Zugriffs-Logs</option>
</select>
<input
type="number"
placeholder="Aelter als (Tage)"
className="w-40 px-3 py-2 border border-slate-300 rounded-lg text-sm"
defaultValue={90}
/>
<button className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium">
Logs bereinigen
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* Info */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-purple-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-semibold text-purple-900">Speicherbegrenzung (Art. 5)</h4>
<p className="text-sm text-purple-800 mt-1">
Personenbezogene Daten duerfen nur so lange gespeichert werden, wie es fuer die Zwecke
erforderlich ist. Die automatische Loeschung stellt die Einhaltung dieser Vorgabe sicher.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,601 +0,0 @@
'use client'
/**
* Service Module Registry Page
*
* Features:
* - List all Breakpilot services with regulation mappings
* - Filter by type, criticality, PII, AI
* - Detail panel with regulations
* - Seed functionality
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
interface ServiceModule {
id: string
name: string
display_name: string
description: string | null
service_type: string
port: number | null
technology_stack: string[]
repository_path: string | null
docker_image: string | null
data_categories: string[]
processes_pii: boolean
processes_health_data: boolean
ai_components: boolean
criticality: string
owner_team: string | null
is_active: boolean
compliance_score: number | null
regulation_count: number
risk_count: number
created_at: string
regulations?: Array<{
code: string
name: string
relevance_level: string
notes: string | null
}>
}
interface ModulesOverview {
total_modules: number
modules_by_type: Record<string, number>
modules_by_criticality: Record<string, number>
modules_processing_pii: number
modules_with_ai: number
average_compliance_score: number | null
regulations_coverage: Record<string, number>
}
const SERVICE_TYPE_CONFIG: Record<string, { icon: string; color: string; bgColor: string }> = {
backend: { icon: '⚙️', color: 'text-blue-700', bgColor: 'bg-blue-100' },
database: { icon: '🗄️', color: 'text-purple-700', bgColor: 'bg-purple-100' },
ai: { icon: '🤖', color: 'text-pink-700', bgColor: 'bg-pink-100' },
communication: { icon: '💬', color: 'text-green-700', bgColor: 'bg-green-100' },
storage: { icon: '📦', color: 'text-orange-700', bgColor: 'bg-orange-100' },
infrastructure: { icon: '🌐', color: 'text-slate-700', bgColor: 'bg-slate-100' },
monitoring: { icon: '📊', color: 'text-cyan-700', bgColor: 'bg-cyan-100' },
security: { icon: '🔒', color: 'text-red-700', bgColor: 'bg-red-100' },
}
const CRITICALITY_CONFIG: Record<string, { color: string; bgColor: string }> = {
critical: { color: 'text-red-700', bgColor: 'bg-red-100' },
high: { color: 'text-orange-700', bgColor: 'bg-orange-100' },
medium: { color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
low: { color: 'text-green-700', bgColor: 'bg-green-100' },
}
const RELEVANCE_CONFIG: Record<string, { color: string; bgColor: string }> = {
critical: { color: 'text-red-700', bgColor: 'bg-red-100' },
high: { color: 'text-orange-700', bgColor: 'bg-orange-100' },
medium: { color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
low: { color: 'text-green-700', bgColor: 'bg-green-100' },
}
export default function ModulesPage() {
const [modules, setModules] = useState<ServiceModule[]>([])
const [overview, setOverview] = useState<ModulesOverview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [typeFilter, setTypeFilter] = useState<string>('all')
const [criticalityFilter, setCriticalityFilter] = useState<string>('all')
const [piiFilter, setPiiFilter] = useState<boolean | null>(null)
const [aiFilter, setAiFilter] = useState<boolean | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const [selectedModule, setSelectedModule] = useState<ServiceModule | null>(null)
const [loadingDetail, setLoadingDetail] = useState(false)
useEffect(() => {
fetchModules()
fetchOverview()
}, [])
const fetchModules = async () => {
try {
setLoading(true)
const params = new URLSearchParams()
if (typeFilter !== 'all') params.append('service_type', typeFilter)
if (criticalityFilter !== 'all') params.append('criticality', criticalityFilter)
if (piiFilter !== null) params.append('processes_pii', String(piiFilter))
if (aiFilter !== null) params.append('ai_components', String(aiFilter))
const url = `/api/admin/compliance/modules${params.toString() ? '?' + params.toString() : ''}`
const res = await fetch(url)
if (!res.ok) throw new Error('Failed to fetch modules')
const data = await res.json()
setModules(data.modules || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}
const fetchOverview = async () => {
try {
const res = await fetch(`/api/admin/compliance/modules/overview`)
if (!res.ok) throw new Error('Failed to fetch overview')
const data = await res.json()
setOverview(data)
} catch (err) {
console.error('Failed to fetch overview:', err)
}
}
const fetchModuleDetail = async (moduleId: string) => {
try {
setLoadingDetail(true)
const res = await fetch(`/api/admin/compliance/modules/${moduleId}`)
if (!res.ok) throw new Error('Failed to fetch module details')
const data = await res.json()
setSelectedModule(data)
} catch (err) {
console.error('Failed to fetch module details:', err)
} finally {
setLoadingDetail(false)
}
}
const seedModules = async (force: boolean = false) => {
try {
const res = await fetch(`/api/admin/compliance/modules/seed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force }),
})
if (!res.ok) throw new Error('Failed to seed modules')
const data = await res.json()
alert(`Seeded ${data.modules_created} modules with ${data.mappings_created} regulation mappings`)
fetchModules()
fetchOverview()
} catch (err) {
alert('Failed to seed modules: ' + (err instanceof Error ? err.message : 'Unknown error'))
}
}
const filteredModules = modules.filter(m => {
if (!searchTerm) return true
const term = searchTerm.toLowerCase()
return (
m.name.toLowerCase().includes(term) ||
m.display_name.toLowerCase().includes(term) ||
(m.description && m.description.toLowerCase().includes(term)) ||
m.technology_stack.some(t => t.toLowerCase().includes(term))
)
})
const modulesByType = filteredModules.reduce((acc, m) => {
const type = m.service_type || 'unknown'
if (!acc[type]) acc[type] = []
acc[type].push(m)
return acc
}, {} as Record<string, ServiceModule[]>)
return (
<div className="min-h-screen bg-slate-50 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Service Module Registry</h1>
<p className="text-slate-600">
Alle {overview?.total_modules || 0} Breakpilot-Services mit Regulation-Mappings
</p>
</div>
<Link
href="/compliance/hub"
className="flex items-center gap-2 text-slate-600 hover:text-slate-800"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Compliance Hub
</Link>
</div>
{/* Page Purpose */}
<PagePurpose
title="Service Module Registry"
purpose="Das Service Registry dokumentiert alle Breakpilot-Microservices und deren regulatorische Anforderungen. Jeder Service wird mit relevanten Regulierungen (DSGVO, AI Act, BSI TR-03161) verknuepft und zeigt an, welche Compliance-Anforderungen gelten."
audience={['Entwickler', 'CISO', 'Compliance Officer', 'Architekten']}
gdprArticles={['Art. 30 (Verzeichnis)', 'Art. 32 (Sicherheit)']}
architecture={{
services: ['Python Backend (FastAPI)', 'compliance_modules Modul'],
databases: ['PostgreSQL (service_modules, module_regulation_mappings Tables)'],
}}
relatedPages={[
{ name: 'Controls', href: '/compliance/controls', description: 'Massnahmen verwalten' },
{ name: 'Risks', href: '/compliance/risks', description: 'Risikomatrix' },
]}
/>
{/* Overview Stats */}
{overview && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 border border-slate-200">
<p className="text-3xl font-bold text-blue-600">{overview.total_modules}</p>
<p className="text-sm text-slate-500">Services</p>
</div>
<div className="bg-white rounded-xl p-4 border border-red-200">
<p className="text-3xl font-bold text-red-600">{overview.modules_by_criticality?.critical || 0}</p>
<p className="text-sm text-slate-500">Critical</p>
</div>
<div className="bg-white rounded-xl p-4 border border-purple-200">
<p className="text-3xl font-bold text-purple-600">{overview.modules_processing_pii}</p>
<p className="text-sm text-slate-500">PII-Processing</p>
</div>
<div className="bg-white rounded-xl p-4 border border-pink-200">
<p className="text-3xl font-bold text-pink-600">{overview.modules_with_ai}</p>
<p className="text-sm text-slate-500">AI-Komponenten</p>
</div>
<div className="bg-white rounded-xl p-4 border border-green-200">
<p className="text-3xl font-bold text-green-600">
{Object.keys(overview.regulations_coverage || {}).length}
</p>
<p className="text-sm text-slate-500">Regulations</p>
</div>
<div className="bg-white rounded-xl p-4 border border-cyan-200">
<p className="text-3xl font-bold text-cyan-600">
{overview.average_compliance_score !== null
? `${overview.average_compliance_score}%`
: 'N/A'}
</p>
<p className="text-sm text-slate-500">Avg. Score</p>
</div>
</div>
)}
{/* Filters */}
<div className="bg-white rounded-xl shadow-sm border p-4 mb-6">
<div className="flex flex-wrap gap-4 items-end">
<div>
<label className="block text-xs text-slate-500 mb-1">Service Type</label>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="all">Alle Typen</option>
<option value="backend">Backend</option>
<option value="database">Database</option>
<option value="ai">AI/ML</option>
<option value="communication">Communication</option>
<option value="storage">Storage</option>
<option value="infrastructure">Infrastructure</option>
<option value="monitoring">Monitoring</option>
<option value="security">Security</option>
</select>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Criticality</label>
<select
value={criticalityFilter}
onChange={(e) => setCriticalityFilter(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">PII</label>
<select
value={piiFilter === null ? 'all' : String(piiFilter)}
onChange={(e) => {
const val = e.target.value
setPiiFilter(val === 'all' ? null : val === 'true')
}}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="true">Verarbeitet PII</option>
<option value="false">Keine PII</option>
</select>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">AI</label>
<select
value={aiFilter === null ? 'all' : String(aiFilter)}
onChange={(e) => {
const val = e.target.value
setAiFilter(val === 'all' ? null : val === 'true')
}}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="true">Mit AI</option>
<option value="false">Ohne AI</option>
</select>
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-xs text-slate-500 mb-1">Suche</label>
<input
type="text"
placeholder="Service, Beschreibung, Technologie..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm w-full"
/>
</div>
<button
onClick={fetchModules}
className="px-4 py-2 bg-slate-100 rounded-lg hover:bg-slate-200 text-sm"
>
Filter
</button>
<button
onClick={() => seedModules(false)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm"
>
Seed
</button>
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 p-4 rounded-lg mb-6">
{error}
</div>
)}
{/* Main Content */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : (
<div className="flex gap-6">
{/* Module List */}
<div className="flex-1 space-y-4">
{Object.entries(modulesByType).map(([type, typeModules]) => (
<div key={type} className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className={`px-4 py-2 border-b ${SERVICE_TYPE_CONFIG[type]?.bgColor || 'bg-slate-100'}`}>
<span className="text-lg mr-2">{SERVICE_TYPE_CONFIG[type]?.icon || '📁'}</span>
<span className={`font-semibold ${SERVICE_TYPE_CONFIG[type]?.color || 'text-slate-700'}`}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</span>
<span className="text-slate-500 ml-2">({typeModules.length})</span>
</div>
<div className="divide-y">
{typeModules.map((module) => (
<div
key={module.id}
onClick={() => fetchModuleDetail(module.name)}
className={`p-4 cursor-pointer hover:bg-slate-50 transition ${
selectedModule?.id === module.id ? 'bg-blue-50' : ''
}`}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{module.display_name}</span>
{module.port && (
<span className="text-xs text-slate-400">:{module.port}</span>
)}
</div>
<div className="text-sm text-slate-500 mt-1">{module.name}</div>
{module.description && (
<div className="text-sm text-slate-600 mt-1 line-clamp-2">
{module.description}
</div>
)}
<div className="flex flex-wrap gap-1 mt-2">
{module.technology_stack.slice(0, 4).map((tech, i) => (
<span key={i} className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">
{tech}
</span>
))}
{module.technology_stack.length > 4 && (
<span className="px-2 py-0.5 text-slate-400 text-xs">
+{module.technology_stack.length - 4}
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`px-2 py-0.5 text-xs rounded ${
CRITICALITY_CONFIG[module.criticality]?.bgColor || 'bg-slate-100'
} ${CRITICALITY_CONFIG[module.criticality]?.color || 'text-slate-700'}`}>
{module.criticality}
</span>
<div className="flex gap-1 mt-1">
{module.processes_pii && (
<span className="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded" title="Verarbeitet PII">
PII
</span>
)}
{module.ai_components && (
<span className="px-1.5 py-0.5 bg-pink-100 text-pink-700 text-xs rounded" title="AI-Komponenten">
AI
</span>
)}
</div>
<div className="text-xs text-slate-400 mt-1">
{module.regulation_count} Regulations
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
{filteredModules.length === 0 && !loading && (
<div className="text-center py-12 text-slate-500 bg-white rounded-xl shadow-sm border">
Keine Module gefunden.
<button
onClick={() => seedModules(false)}
className="text-primary-600 hover:underline ml-1"
>
Jetzt seeden?
</button>
</div>
)}
</div>
{/* Detail Panel */}
{selectedModule && (
<div className="w-96 bg-white rounded-xl shadow-sm border sticky top-6 h-fit">
<div className={`px-4 py-3 border-b ${SERVICE_TYPE_CONFIG[selectedModule.service_type]?.bgColor || 'bg-slate-100'}`}>
<div className="flex items-center justify-between">
<span className="text-lg">{SERVICE_TYPE_CONFIG[selectedModule.service_type]?.icon || '📁'}</span>
<button
onClick={() => setSelectedModule(null)}
className="text-slate-400 hover:text-slate-600"
>
</button>
</div>
<h3 className="font-bold text-lg mt-2">{selectedModule.display_name}</h3>
<div className="text-sm text-slate-600">{selectedModule.name}</div>
</div>
{loadingDetail ? (
<div className="p-4 text-center text-slate-500">Lade Details...</div>
) : (
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
{selectedModule.description && (
<div>
<div className="text-xs text-slate-500 uppercase mb-1">Beschreibung</div>
<div className="text-sm text-slate-700">{selectedModule.description}</div>
</div>
)}
<div className="grid grid-cols-2 gap-2 text-sm">
{selectedModule.port && (
<div>
<span className="text-slate-500">Port:</span>
<span className="ml-1 font-mono">{selectedModule.port}</span>
</div>
)}
<div>
<span className="text-slate-500">Criticality:</span>
<span className={`ml-1 px-1.5 py-0.5 rounded text-xs ${
CRITICALITY_CONFIG[selectedModule.criticality]?.bgColor || ''
} ${CRITICALITY_CONFIG[selectedModule.criticality]?.color || ''}`}>
{selectedModule.criticality}
</span>
</div>
</div>
<div>
<div className="text-xs text-slate-500 uppercase mb-1">Tech Stack</div>
<div className="flex flex-wrap gap-1">
{selectedModule.technology_stack.map((tech, i) => (
<span key={i} className="px-2 py-0.5 bg-slate-100 text-slate-700 text-xs rounded">
{tech}
</span>
))}
</div>
</div>
{selectedModule.data_categories.length > 0 && (
<div>
<div className="text-xs text-slate-500 uppercase mb-1">Daten-Kategorien</div>
<div className="flex flex-wrap gap-1">
{selectedModule.data_categories.map((cat, i) => (
<span key={i} className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded">
{cat}
</span>
))}
</div>
</div>
)}
<div className="flex flex-wrap gap-2">
{selectedModule.processes_pii && (
<span className="px-2 py-1 bg-purple-100 text-purple-700 text-xs rounded">
Verarbeitet PII
</span>
)}
{selectedModule.ai_components && (
<span className="px-2 py-1 bg-pink-100 text-pink-700 text-xs rounded">
AI-Komponenten
</span>
)}
{selectedModule.processes_health_data && (
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">
Gesundheitsdaten
</span>
)}
</div>
{selectedModule.regulations && selectedModule.regulations.length > 0 && (
<div>
<div className="text-xs text-slate-500 uppercase mb-2">
Applicable Regulations ({selectedModule.regulations.length})
</div>
<div className="space-y-2">
{selectedModule.regulations.map((reg, i) => (
<div key={i} className="p-2 bg-slate-50 rounded text-sm">
<div className="flex justify-between items-start">
<span className="font-medium">{reg.code}</span>
<span className={`px-1.5 py-0.5 rounded text-xs ${
RELEVANCE_CONFIG[reg.relevance_level]?.bgColor || 'bg-slate-100'
} ${RELEVANCE_CONFIG[reg.relevance_level]?.color || 'text-slate-700'}`}>
{reg.relevance_level}
</span>
</div>
<div className="text-slate-500 text-xs">{reg.name}</div>
{reg.notes && (
<div className="text-slate-600 text-xs mt-1 italic">{reg.notes}</div>
)}
</div>
))}
</div>
</div>
)}
{selectedModule.owner_team && (
<div>
<div className="text-xs text-slate-500 uppercase mb-1">Owner</div>
<div className="text-sm text-slate-700">{selectedModule.owner_team}</div>
</div>
)}
{selectedModule.repository_path && (
<div>
<div className="text-xs text-slate-500 uppercase mb-1">Repository</div>
<code className="text-xs bg-slate-100 px-2 py-1 rounded block">
{selectedModule.repository_path}
</code>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Regulations Coverage Overview */}
{overview && overview.regulations_coverage && Object.keys(overview.regulations_coverage).length > 0 && (
<div className="bg-white rounded-xl shadow-sm border p-4 mt-6">
<h3 className="font-semibold text-slate-900 mb-4">Regulation Coverage</h3>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Object.entries(overview.regulations_coverage)
.sort(([, a], [, b]) => b - a)
.map(([code, count]) => (
<div key={code} className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600">{count}</div>
<div className="text-xs text-slate-600 truncate" title={code}>{code}</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +0,0 @@
'use client'
import { getCategoryById } from '@/lib/navigation'
import { ModuleCard } from '@/components/common/ModuleCard'
import { PagePurpose } from '@/components/common/PagePurpose'
export default function CompliancePage() {
const category = getCategoryById('compliance')
if (!category) {
return <div>Kategorie nicht gefunden</div>
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title={category.name}
purpose="Diese Kategorie umfasst alle Module fuer Datenschutz, DSGVO-Compliance und rechtliche Dokumentation. Hier verwalten Sie Einwilligungen, bearbeiten Betroffenenanfragen und dokumentieren Audit-Nachweise."
audience={['DSB', 'Compliance Officer', 'Auditoren']}
gdprArticles={['Art. 5 (Rechenschaftspflicht)', 'Art. 7 (Einwilligung)', 'Art. 15-21 (Betroffenenrechte)']}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
collapsible={true}
defaultCollapsed={false}
/>
{/* Modules Grid */}
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{category.modules.map((module) => (
<ModuleCard key={module.id} module={module} category={category} />
))}
</div>
{/* Info Section */}
<div className="mt-8 bg-purple-50 border border-purple-200 rounded-xl p-6">
<h3 className="font-semibold text-purple-800 flex items-center gap-2">
<span>🛡</span>
DSGVO-Konformitaet
</h3>
<p className="text-sm text-purple-700 mt-2">
Alle Module in dieser Kategorie sind darauf ausgelegt, die DSGVO-Anforderungen zu erfuellen.
Die Dokumentation aller Verarbeitungstaetigkeiten erfolgt automatisch und kann jederzeit
fuer Audits exportiert werden.
</p>
</div>
</div>
)
}

View File

@@ -1,446 +0,0 @@
'use client'
/**
* Requirements Page - Alle Compliance-Anforderungen mit Implementation-Status
*
* Features:
* - Liste aller 19 Verordnungen mit URLs zu Originaldokumenten
* - 558+ Requirements mit Implementation-Status
* - Filterung nach Regulation, Status, Prioritaet
* - Detail-Ansicht mit Breakpilot-Interpretation
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// Types
interface Regulation {
id: string
code: string
name: string
full_name: string
regulation_type: string
source_url: string
local_pdf_path?: string
effective_date?: string
description: string
is_active: boolean
requirement_count: number
}
interface Requirement {
id: string
regulation_id: string
regulation_code: string
article: string
paragraph?: string
title: string
description?: string
requirement_text?: string
breakpilot_interpretation?: string
implementation_status: 'not_started' | 'in_progress' | 'implemented' | 'verified' | 'not_applicable'
implementation_details?: string
code_references?: Array<{ file: string; line?: number; description?: string }>
evidence_description?: string
priority: number
is_applicable: boolean
controls_count: number
}
const STATUS_CONFIG: Record<string, { bg: string; text: string; label: string }> = {
not_started: { bg: 'bg-slate-100', text: 'text-slate-700', label: 'Nicht begonnen' },
in_progress: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'In Arbeit' },
implemented: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Implementiert' },
verified: { bg: 'bg-green-100', text: 'text-green-700', label: 'Verifiziert' },
not_applicable: { bg: 'bg-slate-50', text: 'text-slate-500', label: 'N/A' },
}
const PRIORITY_CONFIG: Record<number, { bg: string; text: string; label: string }> = {
1: { bg: 'bg-red-100', text: 'text-red-700', label: 'Kritisch' },
2: { bg: 'bg-orange-100', text: 'text-orange-700', label: 'Hoch' },
3: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Mittel' },
}
const REGULATION_TYPE_LABELS: Record<string, string> = {
eu_regulation: 'EU-Verordnung',
eu_directive: 'EU-Richtlinie',
de_law: 'DE Gesetz',
bsi_standard: 'BSI Standard',
}
export default function RequirementsPage() {
const [regulations, setRegulations] = useState<Regulation[]>([])
const [requirements, setRequirements] = useState<Requirement[]>([])
const [selectedRegulation, setSelectedRegulation] = useState<string | null>(null)
const [selectedRequirement, setSelectedRequirement] = useState<Requirement | null>(null)
const [loading, setLoading] = useState(true)
const [requirementsLoading, setRequirementsLoading] = useState(false)
// Filters
const [statusFilter, setStatusFilter] = useState<string>('')
const [priorityFilter, setPriorityFilter] = useState<string>('')
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
loadRegulations()
}, [])
useEffect(() => {
if (selectedRegulation) {
loadRequirements(selectedRegulation)
}
}, [selectedRegulation])
const loadRegulations = async () => {
setLoading(true)
try {
const res = await fetch('/api/admin/compliance/regulations')
if (res.ok) {
const data = await res.json()
setRegulations(data.regulations || data || [])
}
} catch (err) {
console.error('Failed to load regulations:', err)
} finally {
setLoading(false)
}
}
const loadRequirements = async (regulationCode: string) => {
setRequirementsLoading(true)
try {
const params = new URLSearchParams({ regulation_code: regulationCode })
if (statusFilter) params.set('status', statusFilter)
if (priorityFilter) params.set('priority', priorityFilter)
if (searchQuery) params.set('search', searchQuery)
const res = await fetch(`/api/admin/compliance/requirements?${params}`)
if (res.ok) {
const data = await res.json()
setRequirements(data.requirements || data || [])
}
} catch (err) {
console.error('Failed to load requirements:', err)
} finally {
setRequirementsLoading(false)
}
}
const filteredRequirements = requirements.filter(req => {
if (statusFilter && req.implementation_status !== statusFilter) return false
if (priorityFilter && req.priority !== parseInt(priorityFilter)) return false
if (searchQuery) {
const query = searchQuery.toLowerCase()
return (
req.title.toLowerCase().includes(query) ||
req.article.toLowerCase().includes(query) ||
req.description?.toLowerCase().includes(query)
)
}
return true
})
const totalRequirements = regulations.reduce((sum, r) => sum + (r.requirement_count || 0), 0)
return (
<div className="space-y-6">
<PagePurpose
title="Requirements & Anforderungen"
purpose="Uebersicht aller 558+ Compliance-Anforderungen aus 19 Verordnungen (DSGVO, AI Act, CRA, BSI-TR-03161, etc.). Sehen Sie den Implementation-Status und wie Breakpilot jede Anforderung erfuellt."
audience={['DSB', 'Compliance Officer', 'Entwickler', 'Auditoren']}
gdprArticles={['Art. 5 (Rechenschaftspflicht)', 'Art. 24 (Verantwortung)']}
architecture={{
services: ['Python Backend', 'PostgreSQL'],
databases: ['compliance_regulations', 'compliance_requirements', 'compliance_control_mappings'],
}}
relatedPages={[
{ name: 'Compliance Hub', href: '/compliance/hub', description: 'Dashboard & Uebersicht' },
{ name: 'Controls', href: '/compliance/controls', description: 'Technische Massnahmen' },
{ name: 'Audit Checklist', href: '/compliance/audit-checklist', description: 'Audit durchfuehren' },
]}
/>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl shadow-sm border p-4">
<p className="text-3xl font-bold text-purple-600">{regulations.length}</p>
<p className="text-sm text-slate-600">Verordnungen</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-4">
<p className="text-3xl font-bold text-blue-600">{totalRequirements}</p>
<p className="text-sm text-slate-600">Anforderungen</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-4">
<p className="text-3xl font-bold text-green-600">
{regulations.filter(r => r.regulation_type === 'eu_regulation').length}
</p>
<p className="text-sm text-slate-600">EU-Verordnungen</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-4">
<p className="text-3xl font-bold text-orange-600">
{regulations.filter(r => r.regulation_type === 'bsi_standard').length}
</p>
<p className="text-sm text-slate-600">BSI Standards</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Regulations List */}
<div className="lg:col-span-1 space-y-4">
<h2 className="text-lg font-semibold text-slate-900">Verordnungen & Standards</h2>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<div className="space-y-2 max-h-[70vh] overflow-y-auto pr-2">
{regulations.map((reg) => (
<div
key={reg.id}
onClick={() => setSelectedRegulation(reg.code)}
className={`p-4 rounded-lg border cursor-pointer transition-colors ${
selectedRegulation === reg.code
? 'border-purple-500 bg-purple-50'
: 'border-slate-200 hover:border-slate-300 bg-white'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-mono font-bold text-purple-600">{reg.code}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${
reg.regulation_type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' :
reg.regulation_type === 'eu_directive' ? 'bg-indigo-100 text-indigo-700' :
reg.regulation_type === 'bsi_standard' ? 'bg-orange-100 text-orange-700' :
'bg-slate-100 text-slate-700'
}`}>
{REGULATION_TYPE_LABELS[reg.regulation_type] || reg.regulation_type}
</span>
</div>
<h3 className="font-medium text-slate-900 text-sm">{reg.name}</h3>
<p className="text-xs text-slate-500 mt-1 line-clamp-2">{reg.description}</p>
<div className="flex items-center justify-between mt-3">
<span className="text-xs text-slate-600">
{reg.requirement_count || 0} Anforderungen
</span>
{reg.source_url && (
<a
href={reg.source_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-xs text-purple-600 hover:text-purple-700 flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Original
</a>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Requirements List */}
<div className="lg:col-span-2 space-y-4">
{!selectedRegulation ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="text-lg font-medium text-slate-900">Waehlen Sie eine Verordnung</h3>
<p className="text-slate-500 mt-2">Waehlen Sie eine Verordnung aus der Liste um deren Anforderungen zu sehen.</p>
</div>
) : (
<>
{/* Filters */}
<div className="flex flex-wrap gap-4 items-center">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suche in Anforderungen..."
className="flex-1 min-w-[200px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Alle Status</option>
<option value="not_started">Nicht begonnen</option>
<option value="in_progress">In Arbeit</option>
<option value="implemented">Implementiert</option>
<option value="verified">Verifiziert</option>
<option value="not_applicable">N/A</option>
</select>
<select
value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value)}
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Alle Prioritaeten</option>
<option value="1">Kritisch</option>
<option value="2">Hoch</option>
<option value="3">Mittel</option>
</select>
</div>
{/* Requirements Table */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
{requirementsLoading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : filteredRequirements.length === 0 ? (
<div className="text-center py-12 text-slate-500">
{requirements.length === 0 ? (
<>
<p className="font-medium">Keine Anforderungen gefunden</p>
<p className="text-sm mt-2">Starten Sie den Scraper um Anforderungen zu extrahieren.</p>
<button
onClick={() => {/* TODO: Trigger scraper */}}
className="mt-4 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Anforderungen extrahieren
</button>
</>
) : (
<p>Keine Anforderungen entsprechen den Filterkriterien</p>
)}
</div>
) : (
<div className="divide-y divide-slate-200">
{filteredRequirements.map((req) => {
const statusConfig = STATUS_CONFIG[req.implementation_status] || STATUS_CONFIG.not_started
const priorityConfig = PRIORITY_CONFIG[req.priority] || PRIORITY_CONFIG[2]
return (
<div
key={req.id}
className={`p-4 hover:bg-slate-50 cursor-pointer transition-colors ${
selectedRequirement?.id === req.id ? 'bg-purple-50' : ''
}`}
onClick={() => setSelectedRequirement(selectedRequirement?.id === req.id ? null : req)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm font-medium text-purple-600">
{req.article}
</span>
{req.paragraph && (
<span className="text-xs text-slate-500">({req.paragraph})</span>
)}
<span className={`px-2 py-0.5 text-xs rounded-full ${priorityConfig.bg} ${priorityConfig.text}`}>
{priorityConfig.label}
</span>
</div>
<h4 className="font-medium text-slate-900">{req.title}</h4>
{req.description && (
<p className="text-sm text-slate-600 mt-1 line-clamp-2">{req.description}</p>
)}
</div>
<div className="flex flex-col items-end gap-2 ml-4">
<span className={`px-3 py-1 text-xs rounded-full ${statusConfig.bg} ${statusConfig.text}`}>
{statusConfig.label}
</span>
{req.controls_count > 0 && (
<span className="text-xs text-slate-500">
{req.controls_count} Controls
</span>
)}
</div>
</div>
{/* Expanded Details */}
{selectedRequirement?.id === req.id && (
<div className="mt-4 pt-4 border-t border-slate-200 space-y-4">
{req.requirement_text && (
<div>
<h5 className="text-xs font-medium text-slate-500 uppercase mb-1">Originaltext</h5>
<p className="text-sm text-slate-700 bg-slate-50 p-3 rounded-lg">
{req.requirement_text}
</p>
</div>
)}
{req.breakpilot_interpretation && (
<div>
<h5 className="text-xs font-medium text-slate-500 uppercase mb-1">Breakpilot Interpretation</h5>
<p className="text-sm text-slate-700 bg-purple-50 p-3 rounded-lg border border-purple-100">
{req.breakpilot_interpretation}
</p>
</div>
)}
{req.implementation_details && (
<div>
<h5 className="text-xs font-medium text-slate-500 uppercase mb-1">Implementation</h5>
<p className="text-sm text-slate-700 bg-green-50 p-3 rounded-lg border border-green-100">
{req.implementation_details}
</p>
</div>
)}
{req.code_references && req.code_references.length > 0 && (
<div>
<h5 className="text-xs font-medium text-slate-500 uppercase mb-1">Code-Referenzen</h5>
<div className="space-y-1">
{req.code_references.map((ref, idx) => (
<div key={idx} className="text-sm font-mono bg-slate-100 p-2 rounded">
<span className="text-purple-600">{ref.file}</span>
{ref.line && <span className="text-slate-500">:{ref.line}</span>}
{ref.description && (
<span className="text-slate-600 ml-2">- {ref.description}</span>
)}
</div>
))}
</div>
</div>
)}
{req.evidence_description && (
<div>
<h5 className="text-xs font-medium text-slate-500 uppercase mb-1">Nachweis</h5>
<p className="text-sm text-slate-700">{req.evidence_description}</p>
</div>
)}
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm border border-purple-600 text-purple-600 rounded-lg hover:bg-purple-50">
Mit Claude interpretieren
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Summary */}
{requirements.length > 0 && (
<div className="bg-slate-50 rounded-lg p-4 text-sm text-slate-600">
<p>
<strong>{filteredRequirements.length}</strong> von <strong>{requirements.length}</strong> Anforderungen angezeigt
{statusFilter && <span> (Status: {STATUS_CONFIG[statusFilter]?.label})</span>}
{priorityFilter && <span> (Prioritaet: {PRIORITY_CONFIG[parseInt(priorityFilter)]?.label})</span>}
</p>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,712 +0,0 @@
'use client'
/**
* Risk Matrix Page
*
* Features:
* - Visual 5x5 risk matrix
* - Risk list with CRUD
* - Risk assessment / update
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
interface Risk {
id: string
risk_id: string
title: string
description: string
category: string
likelihood: number
impact: number
inherent_risk: string
mitigating_controls: string[] | null
residual_likelihood: number | null
residual_impact: number | null
residual_risk: string | null
owner: string
status: string
treatment_plan: string
}
const RISK_COLORS: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
critical: 'bg-red-500',
}
const RISK_BG_COLORS: Record<string, string> = {
low: 'bg-green-100 border-green-300',
medium: 'bg-yellow-100 border-yellow-300',
high: 'bg-orange-100 border-orange-300',
critical: 'bg-red-100 border-red-300',
}
const STATUS_OPTIONS = [
{ value: 'open', label: 'Offen' },
{ value: 'mitigated', label: 'Mitigiert' },
{ value: 'accepted', label: 'Akzeptiert' },
{ value: 'transferred', label: 'Transferiert' },
]
const CATEGORY_OPTIONS = [
{ value: 'data_breach', label: 'Datenschutzverletzung' },
{ value: 'compliance_gap', label: 'Compliance-Luecke' },
{ value: 'vendor_risk', label: 'Lieferantenrisiko' },
{ value: 'operational', label: 'Betriebsrisiko' },
{ value: 'technical', label: 'Technisches Risiko' },
{ value: 'legal', label: 'Rechtliches Risiko' },
]
const calculateRiskLevel = (likelihood: number, impact: number): string => {
const score = likelihood * impact
if (score >= 20) return 'critical'
if (score >= 12) return 'high'
if (score >= 6) return 'medium'
return 'low'
}
export default function RisksPage() {
const [risks, setRisks] = useState<Risk[]>([])
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<'matrix' | 'list'>('matrix')
const [selectedRisk, setSelectedRisk] = useState<Risk | null>(null)
const [editModalOpen, setEditModalOpen] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [formData, setFormData] = useState({
risk_id: '',
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [] as string[],
residual_likelihood: null as number | null,
residual_impact: null as number | null,
})
useEffect(() => {
loadRisks()
}, [])
const loadRisks = async () => {
setLoading(true)
try {
const res = await fetch(`/api/admin/compliance/risks`)
if (res.ok) {
const data = await res.json()
setRisks(data.risks || [])
}
} catch (error) {
console.error('Failed to load risks:', error)
} finally {
setLoading(false)
}
}
const openCreateModal = () => {
setFormData({
risk_id: `RISK-${String(risks.length + 1).padStart(3, '0')}`,
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [],
residual_likelihood: null,
residual_impact: null,
})
setCreateModalOpen(true)
}
const openEditModal = (risk: Risk) => {
setSelectedRisk(risk)
setFormData({
risk_id: risk.risk_id,
title: risk.title,
description: risk.description || '',
category: risk.category,
likelihood: risk.likelihood,
impact: risk.impact,
owner: risk.owner || '',
treatment_plan: risk.treatment_plan || '',
status: risk.status,
mitigating_controls: risk.mitigating_controls || [],
residual_likelihood: risk.residual_likelihood,
residual_impact: risk.residual_impact,
})
setEditModalOpen(true)
}
const handleCreate = async () => {
setSaving(true)
try {
const res = await fetch(`/api/admin/compliance/risks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
risk_id: formData.risk_id,
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
mitigating_controls: formData.mitigating_controls,
}),
})
if (res.ok) {
setCreateModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Create failed:', error)
alert('Fehler beim Erstellen')
} finally {
setSaving(false)
}
}
const handleUpdate = async () => {
if (!selectedRisk) return
setSaving(true)
try {
const res = await fetch(`/api/admin/compliance/risks/${selectedRisk.risk_id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
status: formData.status,
mitigating_controls: formData.mitigating_controls,
residual_likelihood: formData.residual_likelihood,
residual_impact: formData.residual_impact,
}),
})
if (res.ok) {
setEditModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Update failed:', error)
alert('Fehler beim Aktualisieren')
} finally {
setSaving(false)
}
}
// Build matrix data structure
const buildMatrix = () => {
const matrix: Record<number, Record<number, Risk[]>> = {}
for (let l = 1; l <= 5; l++) {
matrix[l] = {}
for (let i = 1; i <= 5; i++) {
matrix[l][i] = []
}
}
risks.forEach((risk) => {
if (matrix[risk.likelihood] && matrix[risk.likelihood][risk.impact]) {
matrix[risk.likelihood][risk.impact].push(risk)
}
})
return matrix
}
// Statistics
const stats = {
total: risks.length,
critical: risks.filter(r => r.inherent_risk === 'critical').length,
high: risks.filter(r => r.inherent_risk === 'high').length,
medium: risks.filter(r => r.inherent_risk === 'medium').length,
low: risks.filter(r => r.inherent_risk === 'low').length,
open: risks.filter(r => r.status === 'open').length,
mitigated: risks.filter(r => r.status === 'mitigated').length,
}
const renderMatrix = () => {
const matrix = buildMatrix()
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Risk Matrix (Likelihood x Impact)</h3>
<div className="overflow-x-auto">
<div className="inline-block">
{/* Column headers (Impact) */}
<div className="flex ml-16">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="w-24 text-center text-sm font-medium text-slate-500 pb-2">
Impact {i}
</div>
))}
</div>
{/* Matrix rows */}
{[5, 4, 3, 2, 1].map((likelihood) => (
<div key={likelihood} className="flex items-center">
<div className="w-16 text-sm font-medium text-slate-500 text-right pr-2">
L{likelihood}
</div>
{[1, 2, 3, 4, 5].map((impact) => {
const level = calculateRiskLevel(likelihood, impact)
const cellRisks = matrix[likelihood][impact]
return (
<div
key={impact}
className={`w-24 h-20 border m-0.5 rounded flex flex-col items-center justify-center ${RISK_BG_COLORS[level]}`}
>
{cellRisks.length > 0 && (
<div className="flex flex-wrap gap-1 justify-center">
{cellRisks.map((r) => (
<button
key={r.id}
onClick={() => openEditModal(r)}
className={`px-2 py-0.5 text-xs font-medium rounded text-white ${RISK_COLORS[r.inherent_risk] || 'bg-slate-500'} hover:opacity-80`}
title={r.title}
>
{r.risk_id}
</button>
))}
</div>
)}
</div>
)
})}
</div>
))}
</div>
</div>
{/* Legend */}
<div className="flex gap-4 mt-6 pt-4 border-t">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded" />
<span className="text-sm text-slate-600">Low (1-5)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-500 rounded" />
<span className="text-sm text-slate-600">Medium (6-11)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-orange-500 rounded" />
<span className="text-sm text-slate-600">High (12-19)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-500 rounded" />
<span className="text-sm text-slate-600">Critical (20-25)</span>
</div>
</div>
</div>
)
}
const renderList = () => (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">L x I</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Risiko</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{risks.map((risk) => (
<tr key={risk.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-primary-600">{risk.risk_id}</span>
</td>
<td className="px-4 py-3">
<div>
<p className="font-medium text-slate-900">{risk.title}</p>
{risk.description && (
<p className="text-sm text-slate-500 truncate max-w-md">{risk.description}</p>
)}
</div>
</td>
<td className="px-4 py-3 text-center">
<span className="text-sm text-slate-600">
{CATEGORY_OPTIONS.find((c) => c.value === risk.category)?.label || risk.category}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-mono">{risk.likelihood} x {risk.impact}</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs font-medium rounded-full text-white ${RISK_COLORS[risk.inherent_risk] || 'bg-slate-500'}`}>
{risk.inherent_risk}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
risk.status === 'mitigated' ? 'bg-green-100 text-green-700' :
risk.status === 'accepted' ? 'bg-blue-100 text-blue-700' :
risk.status === 'transferred' ? 'bg-purple-100 text-purple-700' :
'bg-slate-100 text-slate-700'
}`}>
{STATUS_OPTIONS.find(s => s.value === risk.status)?.label || risk.status}
</span>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => openEditModal(risk)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
const renderForm = (isCreate: boolean) => (
<div className="space-y-4">
{isCreate && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Risk ID</label>
<input
type="text"
value={formData.risk_id}
onChange={(e) => setFormData({ ...formData, risk_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{CATEGORY_OPTIONS.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Likelihood (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.likelihood}
onChange={(e) => setFormData({ ...formData, likelihood: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.likelihood}</span>
<span>5</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Impact (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.impact}
onChange={(e) => setFormData({ ...formData, impact: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.impact}</span>
<span>5</span>
</div>
</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-600">
Berechnetes Risiko:{' '}
<span className={`font-medium px-2 py-0.5 rounded text-white ${RISK_COLORS[calculateRiskLevel(formData.likelihood, formData.impact)]}`}>
{calculateRiskLevel(formData.likelihood, formData.impact).toUpperCase()} ({formData.likelihood * formData.impact})
</span>
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verantwortlich</label>
<input
type="text"
value={formData.owner}
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
placeholder="z.B. CISO, DSB"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Behandlungsplan</label>
<textarea
value={formData.treatment_plan}
onChange={(e) => setFormData({ ...formData, treatment_plan: e.target.value })}
placeholder="Massnahmen zur Risikominderung..."
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
)
return (
<div className="min-h-screen bg-slate-50 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Risk Matrix</h1>
<p className="text-slate-600">Risikobewertung & Management</p>
</div>
<Link
href="/compliance/hub"
className="flex items-center gap-2 text-slate-600 hover:text-slate-800"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Compliance Hub
</Link>
</div>
{/* Page Purpose */}
<PagePurpose
title="Risk Matrix"
purpose="Die Risikomatrix visualisiert alle identifizierten Compliance- und Sicherheitsrisiken nach Eintrittswahrscheinlichkeit und Auswirkung. Hier werden Risiken bewertet, Behandlungsplaene erstellt und der Mitigationsstatus verfolgt."
audience={['CISO', 'DSB', 'Compliance Officer', 'Management']}
gdprArticles={['Art. 32 (Risikobewertung)', 'Art. 35 (DSFA)']}
architecture={{
services: ['Python Backend (FastAPI)', 'compliance_risks Modul'],
databases: ['PostgreSQL (compliance_risks Table)'],
}}
relatedPages={[
{ name: 'Controls', href: '/compliance/controls', description: 'Massnahmen zur Risikominderung' },
{ name: 'Audit Checklist', href: '/compliance/audit-checklist', description: 'Anforderungen pruefen' },
]}
/>
{/* Statistics Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 border border-slate-200">
<p className="text-sm text-slate-500">Gesamt</p>
<p className="text-2xl font-bold text-slate-900">{stats.total}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-red-200">
<p className="text-sm text-red-600">Critical</p>
<p className="text-2xl font-bold text-red-700">{stats.critical}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-orange-200">
<p className="text-sm text-orange-600">High</p>
<p className="text-2xl font-bold text-orange-700">{stats.high}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-yellow-200">
<p className="text-sm text-yellow-600">Medium</p>
<p className="text-2xl font-bold text-yellow-700">{stats.medium}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-green-200">
<p className="text-sm text-green-600">Low</p>
<p className="text-2xl font-bold text-green-700">{stats.low}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200">
<p className="text-sm text-slate-500">Offen</p>
<p className="text-2xl font-bold text-slate-700">{stats.open}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-blue-200">
<p className="text-sm text-blue-600">Mitigiert</p>
<p className="text-2xl font-bold text-blue-700">{stats.mitigated}</p>
</div>
</div>
{/* View Toggle & Actions */}
<div className="flex flex-wrap items-center gap-4 mb-6">
{/* View Toggle */}
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => setViewMode('matrix')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'matrix' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
Matrix
</button>
<button
onClick={() => setViewMode('list')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'list' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
Liste
</button>
</div>
<div className="flex-1" />
<button
onClick={openCreateModal}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Risiko hinzufuegen
</button>
</div>
{/* Content */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : risks.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-slate-500 mb-4">Keine Risiken erfasst</p>
<button
onClick={openCreateModal}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Erstes Risiko hinzufuegen
</button>
</div>
) : viewMode === 'matrix' ? (
renderMatrix()
) : (
renderList()
)}
{/* Create Modal */}
{createModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b">
<h3 className="text-lg font-semibold text-slate-900">Neues Risiko</h3>
</div>
<div className="p-6">
{renderForm(true)}
</div>
<div className="p-6 border-t bg-slate-50 flex justify-end gap-3">
<button
onClick={() => setCreateModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={saving}
>
Abbrechen
</button>
<button
onClick={handleCreate}
disabled={saving}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Erstellen...' : 'Erstellen'}
</button>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editModalOpen && selectedRisk && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b">
<h3 className="text-lg font-semibold text-slate-900">Risiko bearbeiten</h3>
<p className="text-sm text-slate-500 font-mono">{selectedRisk.risk_id}</p>
</div>
<div className="p-6">
{renderForm(false)}
</div>
<div className="p-6 border-t bg-slate-50 flex justify-end gap-3">
<button
onClick={() => setEditModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={saving}
>
Abbrechen
</button>
<button
onClick={handleUpdate}
disabled={saving}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,804 +0,0 @@
'use client'
/**
* Source Policy Management Page
*
* Whitelist-based data source management for edu-search-service.
* For auditors: Full audit trail for all changes.
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { SourcesTab } from './components/SourcesTab'
import { OperationsMatrixTab } from './components/OperationsMatrixTab'
import { PIIRulesTab } from './components/PIIRulesTab'
import { AuditTab } from './components/AuditTab'
// API base URL for edu-search-service
// Uses nginx HTTPS proxy on port 8089 when accessed remotely
const getApiBase = () => {
if (typeof window === 'undefined') return 'http://localhost:8088'
const hostname = window.location.hostname
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return 'http://localhost:8088'
}
// Use nginx HTTPS proxy on port 8089 (proxies to edu-search-service:8088)
return `https://${hostname}:8089`
}
interface PolicyStats {
active_policies: number
allowed_sources: number
pii_rules: number
blocked_today: number
blocked_total: number
}
type TabId = 'dashboard' | 'sources' | 'operations' | 'pii' | 'audit'
export default function SourcePolicyPage() {
const [activeTab, setActiveTab] = useState<TabId>('dashboard')
const [stats, setStats] = useState<PolicyStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [apiBase, setApiBase] = useState<string | null>(null)
useEffect(() => {
// Set API base on client side - only runs in browser
const base = getApiBase()
setApiBase(base)
}, [])
useEffect(() => {
// Only fetch when apiBase has been set by the first useEffect
if (apiBase !== null) {
fetchStats()
}
}, [apiBase])
const fetchStats = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/policy-stats`)
if (!res.ok) {
throw new Error('Fehler beim Laden der Statistiken')
}
const data = await res.json()
setStats(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
// Set default stats on error
setStats({
active_policies: 0,
allowed_sources: 0,
pii_rules: 0,
blocked_today: 0,
blocked_total: 0,
})
} finally {
setLoading(false)
}
}
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'dashboard',
name: 'Dashboard',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
),
},
{
id: 'sources',
name: 'Quellen',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
),
},
{
id: 'operations',
name: 'Operations',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
),
},
{
id: 'pii',
name: 'PII-Regeln',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
),
},
{
id: 'audit',
name: 'Audit',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
},
]
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Quellen-Policy"
purpose="Whitelist-basiertes Datenquellen-Management fuer das Bildungssuch-System. Nur offizielle Open-Data-Portale und amtliche Quellen (§5 UrhG). Training mit externen Daten ist VERBOTEN. Fuer Auditoren pruefbar mit vollstaendigem Audit-Trail."
audience={['DSB', 'Compliance Officer', 'Auditor']}
gdprArticles={[
'Art. 5 (Rechtmaessigkeit)',
'Art. 6 (Rechtsgrundlage)',
'Art. 24 (Verantwortung)',
]}
architecture={{
services: ['edu-search-service (Go)', 'PostgreSQL'],
databases: ['source_policies', 'allowed_sources', 'pii_rules', 'policy_audit_log'],
}}
relatedPages={[
{ name: 'Audit Report', href: '/compliance/audit-report', description: 'Compliance-Berichte' },
{ name: 'Controls', href: '/compliance/controls', description: 'Technische Kontrollen' },
{ name: 'Education Search', href: '/education/edu-search', description: 'Bildungsquellen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-purple-600">{stats.active_policies}</div>
<div className="text-sm text-slate-500">Aktive Policies</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats.allowed_sources}</div>
<div className="text-sm text-slate-500">Zugelassene Quellen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-red-600">{stats.blocked_today}</div>
<div className="text-sm text-slate-500">Blockiert (heute)</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-blue-600">{stats.pii_rules}</div>
<div className="text-sm text-slate-500">PII-Regeln</div>
</div>
</div>
)}
{/* Tabs */}
<div className="flex gap-2 mb-6 flex-wrap">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
activeTab === tab.id
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{tab.icon}
{tab.name}
</button>
))}
</div>
{/* Tab Content */}
{apiBase === null ? (
<div className="text-center py-12 text-slate-500">Initialisiere...</div>
) : (
<>
{activeTab === 'dashboard' && (
<DashboardTab stats={stats} loading={loading} apiBase={apiBase} />
)}
{activeTab === 'sources' && <SourcesTab apiBase={apiBase} onUpdate={fetchStats} />}
{activeTab === 'operations' && <OperationsMatrixTab apiBase={apiBase} />}
{activeTab === 'pii' && <PIIRulesTab apiBase={apiBase} onUpdate={fetchStats} />}
{activeTab === 'audit' && <AuditTab apiBase={apiBase} />}
</>
)}
</div>
)
}
// Dashboard Tab Component
function DashboardTab({
stats,
loading,
apiBase,
}: {
stats: PolicyStats | null
loading: boolean
apiBase: string
}) {
const [complianceCheck, setComplianceCheck] = useState({
url: '',
operation: 'lookup',
})
const [checkResult, setCheckResult] = useState<any>(null)
const [checking, setChecking] = useState(false)
const runComplianceCheck = async () => {
if (!complianceCheck.url) return
try {
setChecking(true)
const res = await fetch(`${apiBase}/v1/admin/check-compliance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(complianceCheck),
})
const data = await res.json()
setCheckResult(data)
} catch (err) {
setCheckResult({ error: 'Fehler bei der Pruefung' })
} finally {
setChecking(false)
}
}
if (loading) {
return <div className="text-center py-12 text-slate-500">Lade Dashboard...</div>
}
return (
<div className="space-y-6">
{/* Important Notice */}
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-red-800">Training mit externen Daten: VERBOTEN</h3>
<p className="text-sm text-red-700 mt-1">
Gemaess unserer Datenschutz-Policy ist das Training von KI-Modellen mit gecrawlten Daten
strengstens untersagt. Diese Einschraenkung kann nicht ueber die UI geaendert werden.
</p>
</div>
</div>
</div>
{/* Quick Compliance Check */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Schnell-Pruefung</h3>
<p className="text-sm text-slate-600 mb-4">
Pruefen Sie, ob eine URL in der Whitelist enthalten ist und welche Operationen erlaubt sind.
</p>
<div className="flex flex-col md:flex-row gap-4">
<input
type="url"
value={complianceCheck.url}
onChange={(e) => setComplianceCheck({ ...complianceCheck, url: e.target.value })}
placeholder="https://nibis.de/beispiel-seite"
className="flex-1 px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<select
value={complianceCheck.operation}
onChange={(e) => setComplianceCheck({ ...complianceCheck, operation: e.target.value })}
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="lookup">Lookup (Anzeigen)</option>
<option value="rag">RAG (Retrieval)</option>
<option value="export">Export</option>
<option value="training">Training</option>
</select>
<button
onClick={runComplianceCheck}
disabled={checking || !complianceCheck.url}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{checking ? 'Pruefe...' : 'Pruefen'}
</button>
</div>
{checkResult && (
<div className={`mt-4 p-4 rounded-lg ${checkResult.is_allowed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<div className="flex items-center gap-2 mb-2">
{checkResult.is_allowed ? (
<>
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="font-medium text-green-800">Erlaubt</span>
</>
) : (
<>
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="font-medium text-red-800">
Blockiert: {checkResult.block_reason || 'Nicht in Whitelist'}
</span>
</>
)}
</div>
{checkResult.source && (
<div className="text-sm text-slate-600">
<p><strong>Quelle:</strong> {checkResult.source.name}</p>
<p><strong>Lizenz:</strong> {checkResult.license}</p>
{checkResult.requires_citation && (
<p className="text-amber-600">Zitation erforderlich</p>
)}
</div>
)}
</div>
)}
</div>
{/* Operations Matrix by Source Type */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Zulaessige Operationen nach Quellentyp</h3>
<p className="text-sm text-slate-600 mb-4">
Uebersicht welche Operationen fuer welche Datenquellen erlaubt sind.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="text-left py-2 px-3 font-medium text-slate-700">Quelle / Typ</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Daten</th>
<th className="text-center py-2 px-2 font-medium text-slate-700">Lookup</th>
<th className="text-center py-2 px-2 font-medium text-slate-700">RAG</th>
<th className="text-center py-2 px-2 font-medium text-slate-700">Training</th>
<th className="text-center py-2 px-2 font-medium text-slate-700">Export</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Rechtsgrundlage</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Auflagen / Controls</th>
</tr>
</thead>
<tbody>
{[
{ source: 'Landes-Open-Data-Portale (alle Laender)', data: 'SMD', lookup: 'yes', rag: 'yes', training: 'no', export: 'warn', basis: 'DL-DE-BY-2.0', note: 'Namensnennung, Quellenlink, Zweckbindung' },
{ source: 'Landes-Open-Data-Portale', data: 'PBD', lookup: 'no', rag: 'no', training: 'no', export: 'no', basis: '—', note: 'Technisch filtern (Schema-Block)' },
{ source: 'Regelwerke / Schulordnungen (Ministerien)', data: 'DOK', lookup: 'yes', rag: 'yes', training: 'warn', export: 'warn', basis: 'UrhG §5 / CC / DL', note: 'Nur amtliche Texte, Versions-Hash' },
{ source: 'GovData', data: 'SMD', lookup: 'yes', rag: 'yes', training: 'no', export: 'warn', basis: 'DL-DE-BY-2.0', note: 'Bundesweiter Fallback' },
{ source: 'Einzelschul-Websites', data: 'SMD', lookup: 'warn', rag: 'no', training: 'no', export: 'no', basis: '§60d greift nicht', note: 'Nur manuell, kein Crawling' },
{ source: 'Private Schulverzeichnisse', data: 'SMD', lookup: 'no', rag: 'no', training: 'no', export: 'no', basis: 'Datenbankrecht', note: 'Nicht zulaessig' },
{ source: 'Vom Lehrer eingegebene Daten', data: 'SMD', lookup: 'yes', rag: 'yes', training: 'warn', export: 'warn', basis: 'Art. 6(1)b DSGVO', note: 'Zweckbindung, Namespace' },
{ source: 'Vom Lehrer hochgeladene Dokumente', data: 'DOK', lookup: 'yes', rag: 'yes', training: 'no', export: 'no', basis: 'Art. 6(1)b DSGVO', note: 'Kein Training, nur Session-RAG' },
].map((row, idx) => (
<tr key={idx} className={`border-b border-slate-100 hover:bg-slate-50 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/30'}`}>
<td className="py-2 px-3 font-medium text-slate-800 text-xs">{row.source}</td>
<td className="py-2 px-3">
<span className={`px-1.5 py-0.5 rounded text-xs ${
row.data === 'SMD' ? 'bg-blue-100 text-blue-700' : row.data === 'PBD' ? 'bg-red-100 text-red-700' : 'bg-purple-100 text-purple-700'
}`}>{row.data}</span>
</td>
<td className="py-2 px-2 text-center">{row.lookup === 'yes' ? '✅' : row.lookup === 'warn' ? '⚠️' : '❌'}</td>
<td className="py-2 px-2 text-center">{row.rag === 'yes' ? '✅' : row.rag === 'warn' ? '⚠️' : '❌'}</td>
<td className="py-2 px-2 text-center">{row.training === 'yes' ? '✅' : row.training === 'warn' ? '⚠️' : '❌'}</td>
<td className="py-2 px-2 text-center">{row.export === 'yes' ? '✅' : row.export === 'warn' ? '⚠️' : '❌'}</td>
<td className="py-2 px-3 text-xs">
<span className={`px-1.5 py-0.5 rounded ${
row.basis === '—' ? 'bg-slate-100 text-slate-500' :
row.basis.includes('DSGVO') ? 'bg-blue-100 text-blue-700' :
row.basis.includes('DL-DE') ? 'bg-green-100 text-green-700' :
row.basis.includes('UrhG') || row.basis.includes('CC') ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
}`}>{row.basis}</span>
</td>
<td className="py-2 px-3 text-slate-600 text-xs">{row.note}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Legend and Explanation */}
<div className="mt-4 p-4 bg-slate-50 rounded-lg">
<h4 className="font-medium text-slate-800 mb-3">Geltungsbereich der Matrix</h4>
{/* Datenarten */}
<div className="mb-4">
<div className="text-sm font-medium text-slate-700 mb-2">Datenarten</div>
<div className="flex flex-wrap gap-3 text-xs">
<span className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded">SMD</span>
<span className="text-slate-600">= Schul-Metadaten (Name, Nummer, Schulform, Ort, Traeger)</span>
</span>
<span className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded">PBD</span>
<span className="text-slate-600">= Personenbezogene Daten (Leitung, E-Mail, Telefon)</span>
</span>
<span className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded">DOK</span>
<span className="text-slate-600">= Regelwerke / Ordnungen / Lehrplaene</span>
</span>
</div>
</div>
{/* Verarbeitungsarten mit aufklappbarer Erklärung */}
<details className="group">
<summary className="cursor-pointer text-sm font-medium text-slate-700 mb-2 flex items-center gap-2 hover:text-purple-600">
<svg className="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
Verarbeitungsarten (Details anzeigen)
</summary>
<div className="ml-6 mt-2 space-y-3 text-sm">
<div className="p-3 bg-white rounded border border-slate-200">
<div className="font-medium text-slate-800 flex items-center gap-2">
<span className="text-green-600">Lookup</span>
<span className="text-slate-400">=</span>
<span className="text-slate-600">Auswahl / Validierung / Anzeige</span>
</div>
<p className="text-xs text-slate-500 mt-1">
Daten werden abgerufen und dem Nutzer angezeigt, z.B. bei der Schulauswahl im Onboarding
oder zur Validierung eingegebener Schulnummern. Keine dauerhafte Speicherung oder Weiterverarbeitung.
</p>
</div>
<div className="p-3 bg-white rounded border border-slate-200">
<div className="font-medium text-slate-800 flex items-center gap-2">
<span className="text-blue-600">RAG</span>
<span className="text-slate-400">=</span>
<span className="text-slate-600">Retrieval-Index (Kontext, Zitierquelle)</span>
</div>
<p className="text-xs text-slate-500 mt-1">
Daten werden in einen Vektor-Index aufgenommen und koennen als Kontext fuer KI-Antworten
herangezogen werden. Die Quelle wird zitiert. Keine Veraenderung der Modelgewichte.
</p>
</div>
<div className="p-3 bg-white rounded border border-slate-200">
<div className="font-medium text-slate-800 flex items-center gap-2">
<span className="text-red-600">Training</span>
<span className="text-slate-400">=</span>
<span className="text-slate-600">Modellanpassung / Fine-Tuning</span>
</div>
<p className="text-xs text-slate-500 mt-1">
Daten fliessen in das Training oder Fine-Tuning eines KI-Modells ein und veraendern
dessen Gewichte permanent. <strong className="text-red-600">Grundsaetzlich VERBOTEN</strong> fuer
externe Daten gemaess unserer Datenschutz-Policy.
</p>
</div>
<div className="p-3 bg-white rounded border border-slate-200">
<div className="font-medium text-slate-800 flex items-center gap-2">
<span className="text-amber-600">Export</span>
<span className="text-slate-400">=</span>
<span className="text-slate-600">Weitergabe / Download / API</span>
</div>
<p className="text-xs text-slate-500 mt-1">
Daten werden an Dritte weitergegeben, zum Download bereitgestellt oder ueber eine API
ausgegeben. Erfordert Pruefung der Lizenzbedingungen und ggf. Namensnennung.
</p>
</div>
</div>
</details>
</div>
</div>
{/* KI Use-Case Risk Matrix */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">KI-Use-Case Risikomatrix</h3>
<p className="text-sm text-slate-600 mb-4">
Zulaessigkeit von KI-Anwendungsfaellen nach Datenquelle.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="text-left py-2 px-3 font-medium text-slate-700">KI-Use-Case</th>
<th className="text-center py-2 px-3 font-medium text-slate-700">Open-Data SMD</th>
<th className="text-center py-2 px-3 font-medium text-slate-700">Regelwerke</th>
<th className="text-center py-2 px-3 font-medium text-slate-700">Lehrer-Uploads</th>
<th className="text-center py-2 px-3 font-medium text-slate-700">Risiko</th>
</tr>
</thead>
<tbody>
{[
{ useCase: 'Schul-Auswahl / Onboarding', openData: 'yes', rules: 'na', uploads: 'na', risk: 'low' },
{ useCase: 'Erwartungshorizont-Suche', openData: 'na', rules: 'yes', uploads: 'warn', risk: 'medium' },
{ useCase: 'Klausur-Korrektur (RAG)', openData: 'na', rules: 'warn', uploads: 'yes', risk: 'medium' },
{ useCase: 'Modell-Training', openData: 'no', rules: 'warn', uploads: 'no', risk: 'high' },
{ useCase: 'Auto-Schulerkennung', openData: 'no', rules: 'no', uploads: 'no', risk: 'high' },
].map((row, idx) => (
<tr key={idx} className={`border-b border-slate-100 hover:bg-slate-50 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/30'}`}>
<td className="py-2 px-3 font-medium text-slate-800">{row.useCase}</td>
<td className="py-2 px-3 text-center">{row.openData === 'yes' ? '✅' : row.openData === 'warn' ? '⚠️' : row.openData === 'no' ? '❌' : '—'}</td>
<td className="py-2 px-3 text-center">{row.rules === 'yes' ? '✅' : row.rules === 'warn' ? '⚠️' : row.rules === 'no' ? '❌' : '—'}</td>
<td className="py-2 px-3 text-center">{row.uploads === 'yes' ? '✅' : row.uploads === 'warn' ? '⚠️' : row.uploads === 'no' ? '❌' : '—'}</td>
<td className="py-2 px-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
row.risk === 'low' ? 'bg-green-100 text-green-700' :
row.risk === 'medium' ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
}`}>
{row.risk === 'low' ? 'Niedrig' : row.risk === 'medium' ? 'Mittel' : 'Hoch'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Licenses Info */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Unterstuetzte Lizenzen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">DL-DE-BY-2.0</div>
<div className="text-xs text-slate-500 mt-1">Datenlizenz Deutschland - Namensnennung</div>
<div className="text-xs text-green-600 mt-2">Attribution erforderlich</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">CC-BY</div>
<div className="text-xs text-slate-500 mt-1">Creative Commons Attribution</div>
<div className="text-xs text-green-600 mt-2">Attribution erforderlich</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">CC-BY-SA</div>
<div className="text-xs text-slate-500 mt-1">CC Attribution-ShareAlike</div>
<div className="text-xs text-amber-600 mt-2">Attribution + ShareAlike</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">CC0</div>
<div className="text-xs text-slate-500 mt-1">Public Domain</div>
<div className="text-xs text-slate-400 mt-2">Keine Attribution noetig</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">§5 UrhG</div>
<div className="text-xs text-slate-500 mt-1">Amtliche Werke</div>
<div className="text-xs text-green-600 mt-2">Quellenangabe erforderlich</div>
</div>
</div>
</div>
{/* Technische Controls fuer Attribution */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Technische Controls fuer Attribution</h3>
<p className="text-sm text-slate-600 mb-4">
Massnahmen zur Sicherstellung der lizenzkonformen Quellenangabe im System.
</p>
<div className="space-y-3">
{[
{
id: 'CTRL-SRC-001',
name: 'Attribution bei Schulsuche',
description: 'Bei jedem Suchergebnis aus Open-Data-Portalen wird die Datenquelle, Lizenz und ein Link zum Bereitsteller angezeigt.',
status: 'implemented',
location: 'studio-v2/components/SchoolSearch.tsx',
},
{
id: 'CTRL-SRC-002',
name: 'Attribution bei RAG-Ergebnissen',
description: 'Pro EH-Vorschlag werden Dokumentname, Herausgeber und Lizenz angezeigt. Bei Einfuegen in Gutachten wird Zitation automatisch ergaenzt.',
status: 'implemented',
location: 'studio-v2/components/korrektur/EHSuggestionPanel.tsx',
},
{
id: 'CTRL-SRC-003',
name: 'Export-Attribution',
description: 'Bei PDF-Export wird ein Quellenverzeichnis am Ende eingefuegt. Bei Daten-Export werden Attribution-Metadaten mitgeliefert.',
status: 'planned',
location: 'klausur-service/export',
},
{
id: 'CTRL-SRC-004',
name: 'Attribution-Audit-Trail',
description: 'Logging welche Quellen fuer welche Outputs verwendet wurden. Nachweis fuer Auditoren ueber policy_audit_log.',
status: 'planned',
location: 'edu-search-service/internal/policy/audit.go',
},
].map((ctrl) => (
<div key={ctrl.id} className="p-4 border border-slate-200 rounded-lg hover:bg-slate-50">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-xs px-2 py-0.5 bg-purple-100 text-purple-700 rounded">{ctrl.id}</span>
<span className="font-medium text-slate-800">{ctrl.name}</span>
</div>
<p className="text-sm text-slate-600">{ctrl.description}</p>
<p className="text-xs text-slate-400 mt-1 font-mono">{ctrl.location}</p>
</div>
<span className={`flex-shrink-0 px-2 py-1 rounded text-xs font-medium ${
ctrl.status === 'implemented' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'
}`}>
{ctrl.status === 'implemented' ? 'Implementiert' : 'Geplant'}
</span>
</div>
</div>
))}
</div>
</div>
{/* Erlaubte Referenz-Domains */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Erlaubte Referenz-Domains (Audit-Dokumentation)</h3>
<p className="text-sm text-slate-600 mb-4">
Domains, auf die das System zu Referenz- und Compliance-Zwecken zugreifen darf.
Diese Zugriffe dienen ausschliesslich der rechtssicheren Klassifikation und Dokumentation.
</p>
<div className="space-y-3">
{[
{
domain: 'govdata.de',
reason: 'Der Zugriff auf govdata.de ist dauerhaft erlaubt, da es sich um ein amtliches Open-Data-Portal mit klarer Lizenz handelt. Die Nutzung erfolgt ausschliesslich zu Recherche- und Referenzzwecken, nicht fuer KI-Training.',
type: 'Datenquelle',
},
{
domain: 'creativecommons.org',
reason: 'Der Zugriff auf creativecommons.org ist dauerhaft erlaubt, da es sich um offizielle Lizenztexte handelt, die fuer die rechtssichere Klassifikation und Nutzung von Open-Data-Quellen erforderlich sind.',
type: 'Lizenz-Referenz',
},
{
domain: 'wiki.creativecommons.org',
reason: 'Der Zugriff auf wiki.creativecommons.org ist dauerhaft erlaubt, da es sich um offizielle Lizenzdokumentation handelt, die zur rechtssicheren Klassifikation von Datenquellen erforderlich ist.',
type: 'Lizenz-Dokumentation',
},
{
domain: 'gesetze-im-internet.de',
reason: 'Der Zugriff auf gesetze-im-internet.de ist dauerhaft erlaubt, da es sich um amtliche, urheberrechtsfreie Rechtsquellen (§5 UrhG) handelt, die zur rechtlichen Einordnung und Compliance-Dokumentation erforderlich sind.',
type: 'Rechtsquelle',
},
{
domain: 'nibis.de',
reason: 'Der Zugriff auf nibis.de (Niedersaechsischer Bildungsserver) ist dauerhaft erlaubt fuer den Abruf von Kerncurricula und Erwartungshorizonten. Die Nutzung erfolgt unter DL-DE-BY-2.0 mit Attribution.',
type: 'Bildungsquelle',
},
{
domain: 'kmk.org',
reason: 'Der Zugriff auf kmk.org (Kultusministerkonferenz) ist dauerhaft erlaubt, da KMK-Beschluesse als amtliche Werke nach §5 UrhG frei nutzbar sind. Quellenangabe erforderlich.',
type: 'Amtliche Quelle',
},
].map((item, idx) => (
<div key={item.domain} className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm font-medium text-slate-800">{item.domain}</span>
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">{item.type}</span>
</div>
<p className="text-sm text-slate-600 italic">&quot;{item.reason}&quot;</p>
</div>
</div>
</div>
))}
</div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
<strong>Fuer Auditoren:</strong> Diese Statements dokumentieren die rechtliche Grundlage fuer den Systemzugriff auf externe Domains.
Alle Zugriffe werden im Audit-Log protokolliert.
</div>
</div>
{/* Bundesweite Quellen */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Bundesweite Quellen</h3>
<p className="text-sm text-slate-600 mb-4">
Uebergreifende Open-Data-Portale und amtliche Quellen auf Bundesebene.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-medium text-slate-700">Quelle</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Typ</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Lizenz</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Einsatz</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-2 px-3 font-medium text-slate-800">GovData</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">Bund-ODP</span>
</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">DL-DE-BY-2.0</span>
</td>
<td className="py-2 px-3 text-slate-600">Aggregation / Fallback</td>
</tr>
<tr className="hover:bg-slate-50">
<td className="py-2 px-3 font-medium text-slate-800">Statistische Landesaemter</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">Amtlich</span>
</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 rounded text-xs">variabel</span>
</td>
<td className="py-2 px-3 text-slate-600">Plausibilisierung</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Bundeslaender Open Data Portale */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Bundeslaender Open Data Portale</h3>
<p className="text-sm text-slate-600 mb-4">
Zulaessige Landes-Open-Data-Portale fuer Schulstammdaten und Bildungsinformationen.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-medium text-slate-700">Bundesland</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Zulaessige Quelle</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Lizenz</th>
<th className="text-left py-2 px-3 font-medium text-slate-700 hidden md:table-cell">Hinweise</th>
</tr>
</thead>
<tbody>
{[
{ bl: 'BW', name: 'Baden-Wuerttemberg', source: 'Open Data Baden-Wuerttemberg', license: 'DL-DE-BY-2.0', note: 'Schulverzeichnisse ueber Ministerium / Kommunen' },
{ bl: 'BY', name: 'Bayern', source: 'Open Data Bayern', license: 'DL-DE-BY-2.0', note: 'Amtliche Schulnummern, Standorte' },
{ bl: 'BE', name: 'Berlin', source: 'Datenportal Berlin', license: 'CC-BY', note: 'Sehr gut gepflegte Schulstammdaten' },
{ bl: 'BB', name: 'Brandenburg', source: 'Daten Brandenburg', license: 'DL-DE-BY-2.0', note: 'Kommunale Ergaenzungen pruefen' },
{ bl: 'HB', name: 'Bremen', source: 'Open Data Bremen', license: 'CC-BY', note: 'Kleine Datenmenge, sauber' },
{ bl: 'HH', name: 'Hamburg', source: 'Transparenzportal Hamburg', license: 'DL-DE-BY-2.0', note: 'Sehr gute Metadaten' },
{ bl: 'HE', name: 'Hessen', source: 'Open Data Hessen', license: 'DL-DE-BY-2.0', note: 'Schultraegerdaten' },
{ bl: 'MV', name: 'Mecklenburg-Vorpommern', source: 'Open Data MV', license: 'DL-DE-BY-2.0', note: 'Teilweise CSV/Excel' },
{ bl: 'NI', name: 'Niedersachsen', source: 'Open Data Niedersachsen', license: 'DL-DE-BY-2.0', note: 'Ergaenzend: NIBIS nur Regelwerke, nicht Personen' },
{ bl: 'NW', name: 'Nordrhein-Westfalen', source: 'Open.NRW', license: 'DL-DE-BY-2.0', note: 'Umfangreich, kommunale Qualitaet pruefen' },
{ bl: 'RP', name: 'Rheinland-Pfalz', source: 'Open Data Rheinland-Pfalz', license: 'DL-DE-BY-2.0', note: 'Schulformen & Standorte' },
{ bl: 'SL', name: 'Saarland', source: 'Open Data Saarland', license: 'DL-DE-BY-2.0', note: 'Klein, aber zulaessig' },
{ bl: 'SN', name: 'Sachsen', source: 'Datenportal Sachsen', license: 'DL-DE-BY-2.0', note: 'Gute Pflege' },
{ bl: 'ST', name: 'Sachsen-Anhalt', source: 'Open Data Sachsen-Anhalt', license: 'DL-DE-BY-2.0', note: 'CSV/JSON verfuegbar' },
{ bl: 'SH', name: 'Schleswig-Holstein', source: 'Open Data Schleswig-Holstein', license: 'DL-DE-BY-2.0', note: 'Einheitliche IDs' },
{ bl: 'TH', name: 'Thueringen', source: 'Open Data Thueringen', license: 'DL-DE-BY-2.0', note: 'Kommunale Ergaenzungen' },
].map((item, idx) => (
<tr key={item.bl} className={`border-b border-slate-100 hover:bg-slate-50 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}`}>
<td className="py-2 px-3">
<span className="inline-flex items-center gap-2">
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs font-mono">{item.bl}</span>
<span className="text-slate-700 hidden sm:inline">{item.name}</span>
</span>
</td>
<td className="py-2 px-3 font-medium text-slate-800">{item.source}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
item.license === 'CC-BY'
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700'
}`}>
{item.license}
</span>
</td>
<td className="py-2 px-3 text-slate-500 text-xs hidden md:table-cell">{item.note}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
<strong>Hinweis:</strong> Alle Landes-ODP sind vom Typ &quot;Landes-ODP&quot; und erfordern Attribution gemaess der jeweiligen Lizenz.
</div>
</div>
</div>
)
}

View File

@@ -1,395 +0,0 @@
'use client'
/**
* TOM - Technische und Organisatorische Massnahmen
*
* Art. 32 DSGVO - Sicherheit der Verarbeitung
*/
import { useState } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface TOMCategory {
id: string
title: string
article: string
description: string
measures: {
name: string
description: string
status: 'implemented' | 'partial' | 'planned' | 'not_applicable'
evidence?: string
lastReview?: string
}[]
}
export default function TOMPage() {
const [expandedCategory, setExpandedCategory] = useState<string | null>('encryption')
const tomCategories: TOMCategory[] = [
{
id: 'encryption',
title: 'Verschluesselung',
article: 'Art. 32 Abs. 1 lit. a',
description: 'Pseudonymisierung und Verschluesselung personenbezogener Daten',
measures: [
{
name: 'TLS 1.3 fuer alle Verbindungen',
description: 'Alle HTTP-Verbindungen werden ueber HTTPS mit TLS 1.3 verschluesselt',
status: 'implemented',
evidence: 'SSL Labs A+ Rating, Nginx Config',
lastReview: '2024-12-01'
},
{
name: 'Verschluesselung ruhender Daten',
description: 'PostgreSQL-Datenbank mit AES-256 Verschluesselung (pgcrypto)',
status: 'implemented',
evidence: 'PostgreSQL Config, Encryption Keys in Vault',
lastReview: '2024-12-01'
},
{
name: 'E-Mail-Verschluesselung',
description: 'Optionale PGP-Verschluesselung fuer sensible E-Mails',
status: 'partial',
evidence: 'PGP-Keys verfuegbar, nicht fuer alle Empfaenger',
},
{
name: 'Backup-Verschluesselung',
description: 'Alle Backups werden mit AES-256 verschluesselt gespeichert',
status: 'implemented',
evidence: 'restic Backup Config',
lastReview: '2024-11-15'
},
]
},
{
id: 'access_control',
title: 'Zugriffskontrolle',
article: 'Art. 32 Abs. 1 lit. b',
description: 'Faehigkeit, Vertraulichkeit und Integritaet auf Dauer sicherzustellen',
measures: [
{
name: 'Role-Based Access Control (RBAC)',
description: 'Zugriff basierend auf Rollen: user, admin, data_protection_officer',
status: 'implemented',
evidence: 'consent-service/internal/middleware/auth.go',
lastReview: '2024-12-01'
},
{
name: 'Multi-Faktor-Authentifizierung',
description: '2FA fuer Admin-Zugaenge (TOTP)',
status: 'implemented',
evidence: 'Auth-Service Implementation',
lastReview: '2024-12-01'
},
{
name: 'Passwort-Policy',
description: 'Min. 12 Zeichen, Komplexitaetsanforderungen, bcrypt-Hashing',
status: 'implemented',
evidence: 'consent-service/internal/services/auth_service.go',
lastReview: '2024-12-01'
},
{
name: 'Session-Management',
description: 'JWT mit 24h Ablauf, Refresh-Token-Rotation',
status: 'implemented',
evidence: 'JWT Config, Token-Rotation Logic',
lastReview: '2024-12-01'
},
{
name: 'IP-Whitelisting Admin',
description: 'Admin-Zugang nur von definierten IP-Bereichen',
status: 'planned',
},
]
},
{
id: 'availability',
title: 'Verfuegbarkeit & Belastbarkeit',
article: 'Art. 32 Abs. 1 lit. b',
description: 'Faehigkeit, Verfuegbarkeit und Belastbarkeit der Systeme sicherzustellen',
measures: [
{
name: 'Automatische Backups',
description: 'Taegliche inkrementelle Backups, woechentliche Vollbackups',
status: 'implemented',
evidence: 'restic + cron Jobs',
lastReview: '2024-11-15'
},
{
name: 'Disaster Recovery Plan',
description: 'Dokumentierter Wiederherstellungsplan mit RTO < 4h',
status: 'partial',
evidence: 'DR-Dokumentation in Arbeit',
},
{
name: 'Health Monitoring',
description: 'Prometheus + Grafana fuer System-Monitoring',
status: 'implemented',
evidence: 'Monitoring Stack deployed',
lastReview: '2024-12-01'
},
{
name: 'Rate Limiting',
description: 'API Rate Limiting zum Schutz vor DDoS',
status: 'implemented',
evidence: 'Nginx Rate Limit Config',
lastReview: '2024-12-01'
},
]
},
{
id: 'restore',
title: 'Wiederherstellung',
article: 'Art. 32 Abs. 1 lit. c',
description: 'Rasche Wiederherstellung nach physischem oder technischem Zwischenfall',
measures: [
{
name: 'Backup-Restore-Tests',
description: 'Quartalsweise Tests der Backup-Wiederherstellung',
status: 'partial',
evidence: 'Letzter Test: 2024-10-15',
},
{
name: 'Dokumentierte Recovery-Prozeduren',
description: 'Schritt-fuer-Schritt Anleitungen fuer verschiedene Szenarien',
status: 'implemented',
evidence: 'docs/disaster-recovery/',
lastReview: '2024-11-01'
},
{
name: 'Redundante Datenhaltung',
description: 'Datenbank-Replikation auf zweitem Server',
status: 'planned',
},
]
},
{
id: 'review',
title: 'Regelmaessige Ueberpruefung',
article: 'Art. 32 Abs. 1 lit. d',
description: 'Verfahren zur regelmaessigen Ueberpruefung, Bewertung und Evaluierung',
measures: [
{
name: 'Security Audits',
description: 'Jaehrliche externe Security-Audits',
status: 'implemented',
evidence: 'Letzter Audit: 2024-09',
lastReview: '2024-09-15'
},
{
name: 'Penetration Tests',
description: 'Jaehrliche Penetrationstests durch externen Dienstleister',
status: 'partial',
evidence: 'Naechster Test geplant: Q1 2025',
},
{
name: 'Vulnerability Scanning',
description: 'Woechentliche automatisierte Schwachstellen-Scans',
status: 'implemented',
evidence: 'GitHub Dependabot + Trivy',
lastReview: '2024-12-01'
},
{
name: 'TOM-Review',
description: 'Jaehrliche Ueberpruefung aller TOMs',
status: 'implemented',
evidence: 'Diese Seite',
lastReview: '2024-12-01'
},
]
},
{
id: 'logging',
title: 'Protokollierung & Audit-Trail',
article: 'Art. 32 Abs. 2',
description: 'Nachweis der Einhaltung durch Protokollierung',
measures: [
{
name: 'Zugriffs-Logging',
description: 'Protokollierung aller Zugriffe auf personenbezogene Daten',
status: 'implemented',
evidence: 'consent-service Audit-Logs',
lastReview: '2024-12-01'
},
{
name: 'Aenderungs-Historie',
description: 'Vollstaendige Historie aller Datenänderungen',
status: 'implemented',
evidence: 'audit_logs Tabelle in PostgreSQL',
lastReview: '2024-12-01'
},
{
name: 'Admin-Aktionen-Log',
description: 'Protokollierung aller administrativen Aktionen',
status: 'implemented',
evidence: 'Admin Action Logger',
lastReview: '2024-12-01'
},
{
name: 'Log-Aufbewahrung',
description: 'Logs werden 2 Jahre aufbewahrt, dann automatisch geloescht',
status: 'implemented',
evidence: 'Log Retention Policy',
lastReview: '2024-11-01'
},
]
},
]
const getStatusBadge = (status: string) => {
switch (status) {
case 'implemented':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Umgesetzt</span>
case 'partial':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Teilweise</span>
case 'planned':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Geplant</span>
case 'not_applicable':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">N/A</span>
default:
return null
}
}
const calculateCategoryScore = (category: TOMCategory) => {
const total = category.measures.length
const implemented = category.measures.filter(m => m.status === 'implemented').length
const partial = category.measures.filter(m => m.status === 'partial').length
return Math.round(((implemented + partial * 0.5) / total) * 100)
}
const calculateOverallScore = () => {
let totalMeasures = 0
let implementedScore = 0
tomCategories.forEach(cat => {
cat.measures.forEach(m => {
totalMeasures++
if (m.status === 'implemented') implementedScore += 1
else if (m.status === 'partial') implementedScore += 0.5
})
})
return Math.round((implementedScore / totalMeasures) * 100)
}
return (
<div>
<PagePurpose
title="Technische & Organisatorische Massnahmen (TOMs)"
purpose="Dokumentation aller Sicherheitsmassnahmen gemaess Art. 32 DSGVO. Diese Seite dient als Nachweis fuer Auditoren und den DSB."
audience={['DSB', 'IT-Sicherheit', 'Auditoren', 'Geschaeftsfuehrung']}
gdprArticles={['Art. 32 (Sicherheit der Verarbeitung)']}
architecture={{
services: ['consent-service (Go)', 'backend (Python)', 'Nginx', 'PostgreSQL'],
databases: ['PostgreSQL (verschluesselt)', 'MinIO (Backups)'],
}}
relatedPages={[
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management-System' },
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
{ name: 'Audit', href: '/compliance/audit', description: 'Audit-Dokumentation' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Overall Score */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">TOM-Umsetzungsgrad</h2>
<p className="text-sm text-slate-500 mt-1">Gesamtfortschritt aller technischen und organisatorischen Massnahmen</p>
</div>
<div className="text-right">
<div className={`text-4xl font-bold ${calculateOverallScore() >= 80 ? 'text-green-600' : calculateOverallScore() >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
{calculateOverallScore()}%
</div>
<div className="text-sm text-slate-500">Umgesetzt</div>
</div>
</div>
<div className="mt-4 h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${calculateOverallScore() >= 80 ? 'bg-green-500' : calculateOverallScore() >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${calculateOverallScore()}%` }}
/>
</div>
</div>
{/* TOM Categories */}
<div className="space-y-4">
{tomCategories.map((category) => (
<div key={category.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setExpandedCategory(expandedCategory === category.id ? null : category.id)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center text-lg font-bold ${
calculateCategoryScore(category) >= 80 ? 'bg-green-100 text-green-700' :
calculateCategoryScore(category) >= 50 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{calculateCategoryScore(category)}%
</div>
<div className="text-left">
<h3 className="font-semibold text-slate-900">{category.title}</h3>
<p className="text-sm text-slate-500">{category.article} - {category.description}</p>
</div>
</div>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedCategory === category.id ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expandedCategory === category.id && (
<div className="px-6 pb-6 border-t border-slate-100">
<div className="mt-4 space-y-3">
{category.measures.map((measure, idx) => (
<div key={idx} className="p-4 bg-slate-50 rounded-lg">
<div className="flex items-start justify-between mb-2">
<h4 className="font-medium text-slate-900">{measure.name}</h4>
{getStatusBadge(measure.status)}
</div>
<p className="text-sm text-slate-600 mb-2">{measure.description}</p>
{(measure.evidence || measure.lastReview) && (
<div className="flex flex-wrap gap-4 text-xs text-slate-500">
{measure.evidence && (
<span>Nachweis: <span className="font-mono">{measure.evidence}</span></span>
)}
{measure.lastReview && (
<span>Letzte Pruefung: {measure.lastReview}</span>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
{/* Info */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-purple-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-semibold text-purple-900">Dokumentationspflicht</h4>
<p className="text-sm text-purple-800 mt-1">
Gemaess Art. 32 Abs. 1 DSGVO muessen geeignete technische und organisatorische Massnahmen
implementiert werden, um ein dem Risiko angemessenes Schutzniveau zu gewaehrleisten.
Diese Dokumentation dient als Nachweis fuer Aufsichtsbehoerden.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,334 +0,0 @@
'use client'
/**
* VVT - Verarbeitungsverzeichnis
*
* Art. 30 DSGVO - Verzeichnis von Verarbeitungstaetigkeiten
*/
import { useState } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface ProcessingActivity {
id: string
name: string
purpose: string
legalBasis: string
legalBasisDetail: string
categories: string[]
recipients: string[]
thirdCountryTransfer: boolean
thirdCountryDetails?: string
retentionPeriod: string
technicalMeasures: string[]
lastReview: string
status: 'active' | 'inactive' | 'review_needed'
}
export default function VVTPage() {
const [expandedActivity, setExpandedActivity] = useState<string | null>('user_accounts')
const [filterStatus, setFilterStatus] = useState<string>('all')
const processingActivities: ProcessingActivity[] = [
{
id: 'user_accounts',
name: 'Nutzerkontenverwaltung',
purpose: 'Bereitstellung und Verwaltung von Benutzerkonten fuer die Plattform-Nutzung',
legalBasis: 'Art. 6 Abs. 1 lit. b DSGVO',
legalBasisDetail: 'Vertragserfuellung - Notwendig zur Bereitstellung des Dienstes',
categories: ['Name', 'E-Mail-Adresse', 'Passwort (gehasht)', 'Profilbild (optional)'],
recipients: ['Keine externen Empfaenger'],
thirdCountryTransfer: false,
retentionPeriod: '3 Jahre nach Kontolöschung',
technicalMeasures: ['Verschluesselung', 'Zugriffskontrolle', 'Audit-Logging'],
lastReview: '2024-12-01',
status: 'active'
},
{
id: 'consent_management',
name: 'Einwilligungsverwaltung',
purpose: 'Verwaltung und Dokumentation von Einwilligungen gemaess DSGVO',
legalBasis: 'Art. 6 Abs. 1 lit. c DSGVO',
legalBasisDetail: 'Rechtliche Verpflichtung - Nachweis der Einwilligung',
categories: ['Benutzer-ID', 'Einwilligungstyp', 'Zeitstempel', 'IP-Adresse', 'Version'],
recipients: ['DSB (Datenschutzbeauftragter)', 'Aufsichtsbehoerden bei Anfrage'],
thirdCountryTransfer: false,
retentionPeriod: '6 Jahre nach Widerruf (Nachweispflicht)',
technicalMeasures: ['Unveraenderbarkeit', 'Zeitstempel', 'Audit-Trail'],
lastReview: '2024-12-01',
status: 'active'
},
{
id: 'learning_analytics',
name: 'Lernfortschrittsanalyse',
purpose: 'Analyse des Lernfortschritts zur Verbesserung der Lernerfahrung',
legalBasis: 'Art. 6 Abs. 1 lit. a DSGVO',
legalBasisDetail: 'Einwilligung - Nutzer stimmt der Analyse explizit zu',
categories: ['Benutzer-ID', 'Lernaktivitaeten', 'Testergebnisse', 'Zeitaufwand'],
recipients: ['Lehrer (aggregiert)', 'Eltern (mit Einwilligung)'],
thirdCountryTransfer: false,
retentionPeriod: 'Bis zum Ende des Schuljahres + 1 Jahr',
technicalMeasures: ['Pseudonymisierung', 'Verschluesselung', 'Zugriffsbeschraenkung'],
lastReview: '2024-11-15',
status: 'active'
},
{
id: 'ai_processing',
name: 'KI-gestuetzte Verarbeitung',
purpose: 'Automatische Korrektur und Feedback-Generierung mittels KI',
legalBasis: 'Art. 6 Abs. 1 lit. a DSGVO',
legalBasisDetail: 'Einwilligung - Explizite Zustimmung zur KI-Verarbeitung',
categories: ['Benutzer-ID', 'Eingabetexte', 'Generierte Bewertungen'],
recipients: ['Ollama (lokal)', 'Optional: Cloud-LLM (mit Einwilligung)'],
thirdCountryTransfer: true,
thirdCountryDetails: 'OpenAI (USA) - nur bei expliziter Einwilligung, Standardvertragsklauseln',
retentionPeriod: 'Sofortige Loeschung nach Verarbeitung (keine Speicherung)',
technicalMeasures: ['Anonymisierung wo moeglich', 'Keine Speicherung bei Drittanbietern'],
lastReview: '2024-12-01',
status: 'review_needed'
},
{
id: 'support_requests',
name: 'Support-Anfragen',
purpose: 'Bearbeitung von Support- und Hilfe-Anfragen',
legalBasis: 'Art. 6 Abs. 1 lit. b DSGVO',
legalBasisDetail: 'Vertragserfuellung - Teil des Service-Angebots',
categories: ['Name', 'E-Mail', 'Anfrage-Inhalt', 'Anhaenge'],
recipients: ['Support-Team', 'Entwickler (bei technischen Problemen)'],
thirdCountryTransfer: false,
retentionPeriod: '2 Jahre nach Abschluss des Tickets',
technicalMeasures: ['Zugriffskontrolle', 'Verschluesselung'],
lastReview: '2024-10-01',
status: 'active'
},
{
id: 'newsletter',
name: 'Newsletter-Versand',
purpose: 'Information ueber Updates, Features und relevante Bildungsthemen',
legalBasis: 'Art. 6 Abs. 1 lit. a DSGVO',
legalBasisDetail: 'Einwilligung - Double-Opt-In Verfahren',
categories: ['E-Mail-Adresse', 'Anrede', 'Praeferenzen'],
recipients: ['E-Mail-Provider (Mailpit/SMTP)'],
thirdCountryTransfer: false,
retentionPeriod: 'Bis zum Widerruf',
technicalMeasures: ['Abmelde-Link in jeder E-Mail', 'Verschluesselung'],
lastReview: '2024-11-01',
status: 'active'
},
{
id: 'logging',
name: 'System-Logging',
purpose: 'Sicherheit, Fehleranalyse und Betrieb der Plattform',
legalBasis: 'Art. 6 Abs. 1 lit. f DSGVO',
legalBasisDetail: 'Berechtigtes Interesse - Sicherheit und Betrieb',
categories: ['IP-Adresse', 'Zeitstempel', 'Anfrage-Details', 'User-Agent'],
recipients: ['IT-Administratoren', 'Bei Sicherheitsvorfaellen: Behoerden'],
thirdCountryTransfer: false,
retentionPeriod: '90 Tage (Standard-Logs), 2 Jahre (Security-Logs)',
technicalMeasures: ['IP-Anonymisierung nach 7 Tagen', 'Zugriffsbeschraenkung'],
lastReview: '2024-12-01',
status: 'active'
},
]
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Aktiv</span>
case 'inactive':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">Inaktiv</span>
case 'review_needed':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Pruefung erforderlich</span>
default:
return null
}
}
const filteredActivities = filterStatus === 'all'
? processingActivities
: processingActivities.filter(a => a.status === filterStatus)
return (
<div>
<PagePurpose
title="Verarbeitungsverzeichnis (VVT)"
purpose="Verzeichnis aller Verarbeitungstaetigkeiten gemaess Art. 30 DSGVO. Dokumentiert Zweck, Rechtsgrundlage, Kategorien und Loeschfristen."
audience={['DSB', 'Auditoren', 'Aufsichtsbehoerden']}
gdprArticles={['Art. 30 (Verzeichnis von Verarbeitungstaetigkeiten)']}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management-System' },
{ name: 'TOMs', href: '/compliance/tom', description: 'Technische Massnahmen' },
{ name: 'DSFA', href: '/compliance/dsfa', description: 'Datenschutz-Folgenabschaetzung' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Header */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Verarbeitungstaetigkeiten</h2>
<p className="text-sm text-slate-500 mt-1">{processingActivities.length} dokumentierte Taetigkeiten</p>
</div>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700">
+ Neue Taetigkeit
</button>
</div>
{/* Filter */}
<div className="flex gap-2">
{[
{ value: 'all', label: 'Alle' },
{ value: 'active', label: 'Aktiv' },
{ value: 'review_needed', label: 'Pruefung erforderlich' },
{ value: 'inactive', label: 'Inaktiv' },
].map(filter => (
<button
key={filter.value}
onClick={() => setFilterStatus(filter.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
filterStatus === filter.value
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{filter.label}
</button>
))}
</div>
</div>
{/* Activities List */}
<div className="space-y-4">
{filteredActivities.map((activity) => (
<div key={activity.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setExpandedActivity(expandedActivity === activity.id ? null : activity.id)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="text-left">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-slate-900">{activity.name}</h3>
{getStatusBadge(activity.status)}
{activity.thirdCountryTransfer && (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
Drittland-Transfer
</span>
)}
</div>
<p className="text-sm text-slate-500 mt-1">{activity.purpose}</p>
</div>
</div>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedActivity === activity.id ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expandedActivity === activity.id && (
<div className="px-6 pb-6 border-t border-slate-100">
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Rechtsgrundlage</h4>
<p className="font-semibold text-slate-900">{activity.legalBasis}</p>
<p className="text-sm text-slate-600">{activity.legalBasisDetail}</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Datenkategorien</h4>
<div className="flex flex-wrap gap-2">
{activity.categories.map((cat, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-700 rounded text-sm">
{cat}
</span>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Empfaenger</h4>
<ul className="text-sm text-slate-700 list-disc list-inside">
{activity.recipients.map((rec, idx) => (
<li key={idx}>{rec}</li>
))}
</ul>
</div>
</div>
{/* Right Column */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Loeschfrist</h4>
<p className="text-slate-700">{activity.retentionPeriod}</p>
</div>
{activity.thirdCountryTransfer && activity.thirdCountryDetails && (
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Drittland-Transfer</h4>
<p className="text-slate-700">{activity.thirdCountryDetails}</p>
</div>
)}
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Technische Massnahmen</h4>
<div className="flex flex-wrap gap-2">
{activity.technicalMeasures.map((measure, idx) => (
<span key={idx} className="px-2 py-1 bg-green-100 text-green-700 rounded text-sm">
{measure}
</span>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Letzte Pruefung</h4>
<p className="text-slate-700">{activity.lastReview}</p>
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-100 flex gap-2">
<button className="px-3 py-1.5 text-sm text-purple-600 hover:text-purple-700 font-medium">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-700">
PDF exportieren
</button>
</div>
</div>
)}
</div>
))}
</div>
{/* Info */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-purple-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-semibold text-purple-900">Pflicht zur Fuehrung</h4>
<p className="text-sm text-purple-800 mt-1">
Gemaess Art. 30 DSGVO ist jeder Verantwortliche verpflichtet, ein Verzeichnis aller
Verarbeitungstaetigkeiten zu fuehren. Dieses Verzeichnis muss der Aufsichtsbehoerde
auf Anfrage zur Verfuegung gestellt werden.
</p>
</div>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -129,7 +129,7 @@ export default function DashboardPage() {
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">Neueste Datenschutzanfragen</h3>
<Link href="/compliance/dsr" className="text-sm text-primary-600 hover:text-primary-700">
<Link href="/sdk/dsr" className="text-sm text-primary-600 hover:text-primary-700">
Alle anzeigen
</Link>
</div>

View File

@@ -122,31 +122,31 @@ const ADMIN_SCREENS: ScreenDefinition[] = [
{ id: 'admin-backlog', name: 'Production Backlog', description: 'Go-Live Checkliste', category: 'overview', icon: '📝', url: '/backlog' },
{ id: 'admin-rbac', name: 'RBAC', description: 'Rollen & Berechtigungen', category: 'overview', icon: '👥', url: '/rbac' },
// === DSGVO (Violet #7c3aed) ===
{ id: 'admin-consent', name: 'Consent Verwaltung', description: 'Rechtliche Dokumente & Versionen', category: 'dsgvo', icon: '📄', url: '/dsgvo/consent' },
{ id: 'admin-dsr', name: 'Datenschutzanfragen', description: 'DSGVO Art. 15-21', category: 'dsgvo', icon: '🔒', url: '/dsgvo/dsr' },
{ id: 'admin-einwilligungen', name: 'Einwilligungen', description: 'Nutzer-Consent Uebersicht', category: 'dsgvo', icon: '', url: '/dsgvo/einwilligungen' },
{ id: 'admin-vvt', name: 'VVT', description: 'Verarbeitungsverzeichnis Art. 30', category: 'dsgvo', icon: '📋', url: '/dsgvo/vvt' },
{ id: 'admin-dsfa', name: 'DSFA', description: 'Datenschutz-Folgenabschaetzung', category: 'dsgvo', icon: '⚖️', url: '/dsgvo/dsfa' },
{ id: 'admin-tom', name: 'TOMs', description: 'Technische & Org. Massnahmen', category: 'dsgvo', icon: '🛡', url: '/dsgvo/tom' },
{ id: 'admin-loeschfristen', name: 'Loeschfristen', description: 'Aufbewahrung & Deadlines', category: 'dsgvo', icon: '🗑', url: '/dsgvo/loeschfristen' },
{ id: 'admin-advisory-board', name: 'Advisory Board', description: 'KI-Use-Case Pruefung', category: 'dsgvo', icon: '🧑‍⚖', url: '/dsgvo/advisory-board' },
{ id: 'admin-escalations', name: 'Eskalations-Queue', description: 'DSB Review & Freigabe', category: 'dsgvo', icon: '🚨', url: '/dsgvo/escalations' },
// === COMPLIANCE (Purple #9333ea) ===
{ id: 'admin-compliance-hub', name: 'Compliance Hub', description: 'Zentrales GRC Dashboard', category: 'compliance', icon: '✅', url: '/compliance/hub' },
{ id: 'admin-audit-checklist', name: 'Audit Checkliste', description: '476 Anforderungen pruefen', category: 'compliance', icon: '📋', url: '/compliance/audit-checklist' },
{ id: 'admin-requirements', name: 'Requirements', description: '558+ aus 19 Verordnungen', category: 'compliance', icon: '📜', url: '/compliance/requirements' },
{ id: 'admin-controls', name: 'Controls', description: '474 Control-Mappings', category: 'compliance', icon: '🎛️', url: '/compliance/controls' },
{ id: 'admin-evidence', name: 'Evidence', description: 'Nachweise & Dokumentation', category: 'compliance', icon: '📎', url: '/compliance/evidence' },
{ id: 'admin-risks', name: 'Risiken', description: 'Risk Matrix & Register', category: 'compliance', icon: '⚠️', url: '/compliance/risks' },
{ id: 'admin-audit-report', name: 'Audit Report', description: 'PDF Audit-Berichte', category: 'compliance', icon: '📊', url: '/compliance/audit-report' },
{ id: 'admin-modules', name: 'Service Registry', description: '30+ Service-Module', category: 'compliance', icon: '🔧', url: '/compliance/modules' },
{ id: 'admin-dsms', name: 'DSMS', description: 'Datenschutz-Management-System', category: 'compliance', icon: '🏛️', url: '/compliance/dsms' },
{ id: 'admin-compliance-workflow', name: 'Workflow', description: 'Freigabe-Workflows', category: 'compliance', icon: '🔄', url: '/compliance/workflow' },
{ id: 'admin-source-policy', name: 'Quellen-Policy', description: 'Datenquellen & Compliance', category: 'compliance', icon: '📚', url: '/compliance/source-policy' },
{ id: 'admin-ai-act', name: 'EU-AI-Act', description: 'KI-Risikoklassifizierung', category: 'compliance', icon: '🤖', url: '/compliance/ai-act' },
{ id: 'admin-obligations', name: 'Pflichten', description: 'NIS2, DSGVO, AI Act', category: 'compliance', icon: '⚡', url: '/compliance/obligations' },
// === COMPLIANCE SDK (Violet #8b5cf6) ===
// DSGVO - Datenschutz & Betroffenenrechte
{ id: 'admin-consent', name: 'Consent Verwaltung', description: 'Rechtliche Dokumente & Versionen', category: 'sdk', icon: '📄', url: '/sdk/consent-management' },
{ id: 'admin-dsr', name: 'Datenschutzanfragen', description: 'DSGVO Art. 15-21', category: 'sdk', icon: '🔒', url: '/sdk/dsr' },
{ id: 'admin-einwilligungen', name: 'Einwilligungen', description: 'Nutzer-Consent Uebersicht', category: 'sdk', icon: '', url: '/sdk/einwilligungen' },
{ id: 'admin-vvt', name: 'VVT', description: 'Verarbeitungsverzeichnis Art. 30', category: 'sdk', icon: '📋', url: '/sdk/vvt' },
{ id: 'admin-dsfa', name: 'DSFA', description: 'Datenschutz-Folgenabschaetzung', category: 'sdk', icon: '', url: '/sdk/dsfa' },
{ id: 'admin-tom', name: 'TOMs', description: 'Technische & Org. Massnahmen', category: 'sdk', icon: '🛡', url: '/sdk/tom' },
{ id: 'admin-loeschfristen', name: 'Loeschfristen', description: 'Aufbewahrung & Deadlines', category: 'sdk', icon: '🗑', url: '/sdk/loeschfristen' },
{ id: 'admin-advisory-board', name: 'Advisory Board', description: 'KI-Use-Case Pruefung', category: 'sdk', icon: '🧑‍⚖️', url: '/sdk/advisory-board' },
{ id: 'admin-escalations', name: 'Eskalations-Queue', description: 'DSB Review & Freigabe', category: 'sdk', icon: '🚨', url: '/sdk/escalations' },
// Compliance - Audit, GRC & Regulatorik
{ id: 'admin-compliance-hub', name: 'Compliance Hub', description: 'Zentrales GRC Dashboard', category: 'sdk', icon: '✅', url: '/sdk/compliance-hub' },
{ id: 'admin-audit-checklist', name: 'Audit Checkliste', description: '476 Anforderungen pruefen', category: 'sdk', icon: '📋', url: '/sdk/audit-checklist' },
{ id: 'admin-requirements', name: 'Requirements', description: '558+ aus 19 Verordnungen', category: 'sdk', icon: '📜', url: '/sdk/requirements' },
{ id: 'admin-controls', name: 'Controls', description: '474 Control-Mappings', category: 'sdk', icon: '🎛️', url: '/sdk/controls' },
{ id: 'admin-evidence', name: 'Evidence', description: 'Nachweise & Dokumentation', category: 'sdk', icon: '📎', url: '/sdk/evidence' },
{ id: 'admin-risks', name: 'Risiken', description: 'Risk Matrix & Register', category: 'sdk', icon: '⚠️', url: '/sdk/risks' },
{ id: 'admin-audit-report', name: 'Audit Report', description: 'PDF Audit-Berichte', category: 'sdk', icon: '📊', url: '/sdk/audit-report' },
{ id: 'admin-modules', name: 'Service Registry', description: '30+ Service-Module', category: 'sdk', icon: '🔧', url: '/sdk/modules' },
{ id: 'admin-dsms', name: 'DSMS', description: 'Datenschutz-Management-System', category: 'sdk', icon: '🏛️', url: '/sdk/dsms' },
{ id: 'admin-compliance-workflow', name: 'Workflow', description: 'Freigabe-Workflows', category: 'sdk', icon: '🔄', url: '/sdk/workflow' },
{ id: 'admin-source-policy', name: 'Quellen-Policy', description: 'Datenquellen & Compliance', category: 'sdk', icon: '📚', url: '/sdk/source-policy' },
{ id: 'admin-ai-act', name: 'EU-AI-Act', description: 'KI-Risikoklassifizierung', category: 'sdk', icon: '🤖', url: '/sdk/ai-act' },
{ id: 'admin-obligations', name: 'Pflichten', description: 'NIS2, DSGVO, AI Act', category: 'sdk', icon: '⚡', url: '/sdk/obligations' },
// === KI & AUTOMATISIERUNG (Teal #14b8a6) ===
{ id: 'admin-llm-compare', name: 'LLM Vergleich', description: 'KI-Provider Vergleich', category: 'ai', icon: '🤖', url: '/ai/llm-compare' },

View File

@@ -1,643 +0,0 @@
'use client'
/**
* UCCA - System Documentation Page
*
* Displays architecture documentation, auditor information,
* and transparency data for the UCCA compliance system.
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import Link from 'next/link'
// ============================================================================
// Types
// ============================================================================
type DocTab = 'overview' | 'architecture' | 'auditor' | 'rules' | 'legal-corpus'
interface Rule {
code: string
category: string
title: string
description: string
severity: string
gdpr_ref: string
rationale?: string
risk_add?: number
}
interface Pattern {
id: string
title: string
description: string
benefit?: string
effort?: string
risk_reduction?: number
}
interface Control {
id: string
title: string
description: string
gdpr_ref?: string
effort?: string
}
interface LegalCorpusStats {
total_chunks: number
regulations: {
code: string
name: string
chunks: number
type: string
}[]
}
// ============================================================================
// API Configuration
// ============================================================================
const API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'https://macmini:8090'
// ============================================================================
// Main Component
// ============================================================================
export default function DocumentationPage() {
const [activeTab, setActiveTab] = useState<DocTab>('overview')
const [rules, setRules] = useState<Rule[]>([])
const [patterns, setPatterns] = useState<Pattern[]>([])
const [controls, setControls] = useState<Control[]>([])
const [policyVersion, setPolicyVersion] = useState<string>('')
const [legalStats, setLegalStats] = useState<LegalCorpusStats | null>(null)
const [loading, setLoading] = useState(false)
// Fetch rules, patterns, and controls for transparency
useEffect(() => {
const fetchData = async () => {
setLoading(true)
try {
// Fetch rules
const rulesRes = await fetch(`${API_BASE}/sdk/v1/ucca/rules`, {
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000000' }
})
if (rulesRes.ok) {
const rulesData = await rulesRes.json()
setRules(rulesData.rules || [])
setPolicyVersion(rulesData.policy_version || '')
}
// Fetch patterns
const patternsRes = await fetch(`${API_BASE}/sdk/v1/ucca/patterns`, {
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000000' }
})
if (patternsRes.ok) {
const patternsData = await patternsRes.json()
setPatterns(patternsData.patterns || [])
}
// Fetch controls
const controlsRes = await fetch(`${API_BASE}/sdk/v1/ucca/controls`, {
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000000' }
})
if (controlsRes.ok) {
const controlsData = await controlsRes.json()
setControls(controlsData.controls || [])
}
} catch (error) {
console.error('Failed to fetch documentation data:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
// ============================================================================
// Tab Content Renderers
// ============================================================================
const renderOverview = () => (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<div className="text-4xl mb-3">📋</div>
<h3 className="font-semibold text-slate-800 mb-2">Deterministische Regeln</h3>
<div className="text-3xl font-bold text-primary-600">{rules.length}</div>
<p className="text-sm text-slate-500 mt-2">
Alle Entscheidungen basieren auf transparenten, nachvollziehbaren Regeln.
</p>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<div className="text-4xl mb-3">🏗</div>
<h3 className="font-semibold text-slate-800 mb-2">Architektur-Patterns</h3>
<div className="text-3xl font-bold text-green-600">{patterns.length}</div>
<p className="text-sm text-slate-500 mt-2">
Best-Practice-Loesungen fuer datenschutzkonforme KI-Systeme.
</p>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<div className="text-4xl mb-3">🛡</div>
<h3 className="font-semibold text-slate-800 mb-2">Compliance-Kontrollen</h3>
<div className="text-3xl font-bold text-blue-600">{controls.length}</div>
<p className="text-sm text-slate-500 mt-2">
Technische und organisatorische Massnahmen.
</p>
</div>
</div>
<div className="bg-gradient-to-br from-primary-50 to-blue-50 rounded-xl border border-primary-200 p-6">
<h3 className="font-semibold text-primary-800 text-lg mb-4">Was ist UCCA?</h3>
<div className="prose prose-sm max-w-none text-slate-700">
<p>
<strong>UCCA (Use-Case Compliance & Feasibility Advisor)</strong> ist ein deterministisches
Compliance-Pruefwerkzeug, das Organisationen bei der Bewertung geplanter KI-Anwendungsfaelle
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit unterstuetzt.
</p>
<h4 className="text-primary-700 mt-4">Kernprinzipien</h4>
<ul className="space-y-2">
<li>
<strong>Determinismus:</strong> Alle Entscheidungen basieren auf transparenten Regeln.
Die KI trifft KEINE autonomen Entscheidungen.
</li>
<li>
<strong>Transparenz:</strong> Alle Regeln, Kontrollen und Patterns sind einsehbar.
</li>
<li>
<strong>Human-in-the-Loop:</strong> Kritische Entscheidungen erfordern immer
menschliche Pruefung durch DSB oder Legal.
</li>
<li>
<strong>Rechtsgrundlage:</strong> Jede Regel referenziert konkrete DSGVO-Artikel.
</li>
</ul>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-6">
<h3 className="font-semibold text-amber-800 mb-3 flex items-center gap-2">
<span></span>
Wichtiger Hinweis zur KI-Nutzung
</h3>
<p className="text-amber-700">
Das System verwendet KI (LLM) <strong>ausschliesslich zur Erklaerung</strong> bereits
getroffener Regelentscheidungen. Die eigentliche Compliance-Bewertung erfolgt
<strong> rein deterministisch</strong> durch die Policy Engine. BLOCK-Entscheidungen
koennen NICHT durch KI ueberschrieben werden.
</p>
</div>
</div>
)
const renderArchitecture = () => (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-800 text-lg mb-4">Systemarchitektur</h3>
{/* ASCII Diagram */}
<div className="bg-slate-900 text-green-400 p-6 rounded-lg font-mono text-sm overflow-x-auto">
<pre>{`
┌─────────────────────────────────────────────────────────────────────┐
│ Frontend (Next.js) │
│ admin-v2:3000/dsgvo/advisory-board │
└───────────────────────────────┬─────────────────────────────────────┘
│ HTTPS
┌─────────────────────────────────────────────────────────────────────┐
│ AI Compliance SDK (Go) │
│ Port 8090 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Policy Engine │ │
│ │ ┌───────────────────────────────────────────────────────┐ │ │
│ │ │ YAML-basierte Regeln (ucca_policy_v1.yaml) │ │ │
│ │ │ ~45 Regeln in 7 Kategorien │ │ │
│ │ │ Deterministisch - Kein LLM in Entscheidungslogik │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │ Controls │ │ Patterns │ │ Examples │ │ │
│ │ │ Library │ │ Library │ │ Library │ │ │
│ │ └────────────────┘ └────────────────┘ └────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ LLM Integration │ │ Legal RAG │──────┐ │
│ │ (nur Explain) │ │ Client │ │ │
│ └──────────────────┘ └──────────────────┘ │ │
└─────────────────────────────┬────────────────────┼──────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Datenschicht │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ PostgreSQL │ │ Qdrant │ │
│ │ (Assessments, │ │ (Legal Corpus, │ │
│ │ Escalations) │ │ 2,274 Chunks) │ │
│ └────────────────────┘ └────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
`}</pre>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<h4 className="font-medium text-blue-800 mb-2">Datenfluss</h4>
<ol className="text-sm text-blue-700 list-decimal list-inside space-y-1">
<li>Benutzer beschreibt Use Case im Frontend</li>
<li>Policy Engine evaluiert gegen alle Regeln</li>
<li>Ergebnis mit Controls + Patterns zurueck</li>
<li>Optional: LLM erklaert das Ergebnis</li>
<li>Bei Risiko: Automatische Eskalation</li>
</ol>
</div>
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<h4 className="font-medium text-green-800 mb-2">Sicherheitsmerkmale</h4>
<ul className="text-sm text-green-700 list-disc list-inside space-y-1">
<li>TLS 1.3 Verschluesselung</li>
<li>RBAC mit Tenant-Isolation</li>
<li>JWT-basierte Authentifizierung</li>
<li>Audit-Trail aller Aktionen</li>
<li>Keine Rohtext-Speicherung (nur Hash)</li>
</ul>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-800 text-lg mb-4">Eskalations-Workflow</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-medium text-slate-600">Level</th>
<th className="text-left py-2 px-3 font-medium text-slate-600">Ausloeser</th>
<th className="text-left py-2 px-3 font-medium text-slate-600">Pruefer</th>
<th className="text-left py-2 px-3 font-medium text-slate-600">SLA</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-slate-100 bg-green-50">
<td className="py-2 px-3 font-medium text-green-700">E0</td>
<td className="py-2 px-3 text-slate-600">Nur INFO-Regeln, Risiko &lt; 20</td>
<td className="py-2 px-3 text-slate-600">Automatisch</td>
<td className="py-2 px-3 text-slate-600">-</td>
</tr>
<tr className="border-b border-slate-100 bg-yellow-50">
<td className="py-2 px-3 font-medium text-yellow-700">E1</td>
<td className="py-2 px-3 text-slate-600">WARN-Regeln, Risiko 20-40</td>
<td className="py-2 px-3 text-slate-600">Team-Lead</td>
<td className="py-2 px-3 text-slate-600">24h / 72h</td>
</tr>
<tr className="border-b border-slate-100 bg-orange-50">
<td className="py-2 px-3 font-medium text-orange-700">E2</td>
<td className="py-2 px-3 text-slate-600">Art. 9 Daten, DSFA empfohlen, Risiko 40-60</td>
<td className="py-2 px-3 text-slate-600">DSB</td>
<td className="py-2 px-3 text-slate-600">8h / 48h</td>
</tr>
<tr className="bg-red-50">
<td className="py-2 px-3 font-medium text-red-700">E3</td>
<td className="py-2 px-3 text-slate-600">BLOCK-Regeln, Art. 22, Risiko &gt; 60</td>
<td className="py-2 px-3 text-slate-600">DSB + Legal</td>
<td className="py-2 px-3 text-slate-600">4h / 24h</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
)
const renderAuditorInfo = () => (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-800 text-lg mb-4">
Dokumentation fuer externe Auditoren
</h3>
<p className="text-slate-600 mb-4">
Diese Dokumentation erfuellt die Anforderungen nach Art. 30 DSGVO (Verzeichnis von
Verarbeitungstaetigkeiten) und dient als Grundlage fuer Audits nach Art. 32 DSGVO.
</p>
<div className="space-y-4">
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<h4 className="font-medium text-slate-800 mb-2">1. Zweck des Systems</h4>
<p className="text-sm text-slate-600">
UCCA ist ein Compliance-Pruefwerkzeug zur Bewertung geplanter KI-Anwendungsfaelle
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit. Es unterstuetzt Organisationen
bei der Einhaltung der DSGVO und des AI Acts.
</p>
</div>
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<h4 className="font-medium text-slate-800 mb-2">2. Rechtsgrundlage</h4>
<ul className="text-sm text-slate-600 list-disc list-inside space-y-1">
<li><strong>Art. 6 Abs. 1 lit. c DSGVO</strong> - Erfuellung rechtlicher Verpflichtungen</li>
<li><strong>Art. 6 Abs. 1 lit. f DSGVO</strong> - Berechtigte Interessen (Compliance-Management)</li>
</ul>
</div>
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<h4 className="font-medium text-slate-800 mb-2">3. Verarbeitete Datenkategorien</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm mt-2">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-2 font-medium text-slate-600">Kategorie</th>
<th className="text-left py-2 px-2 font-medium text-slate-600">Speicherung</th>
<th className="text-left py-2 px-2 font-medium text-slate-600">Aufbewahrung</th>
</tr>
</thead>
<tbody className="text-slate-600">
<tr className="border-b border-slate-100">
<td className="py-2 px-2">Use-Case-Beschreibung</td>
<td className="py-2 px-2">Nur Hash (SHA-256)</td>
<td className="py-2 px-2">10 Jahre</td>
</tr>
<tr className="border-b border-slate-100">
<td className="py-2 px-2">Bewertungsergebnis</td>
<td className="py-2 px-2">Vollstaendig</td>
<td className="py-2 px-2">10 Jahre</td>
</tr>
<tr className="border-b border-slate-100">
<td className="py-2 px-2">Audit-Trail</td>
<td className="py-2 px-2">Vollstaendig</td>
<td className="py-2 px-2">10 Jahre</td>
</tr>
<tr>
<td className="py-2 px-2">Eskalations-Historie</td>
<td className="py-2 px-2">Vollstaendig</td>
<td className="py-2 px-2">10 Jahre</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<h4 className="font-medium text-slate-800 mb-2">4. Keine autonomen KI-Entscheidungen</h4>
<p className="text-sm text-slate-600">
Das System trifft <strong>KEINE automatisierten Einzelentscheidungen</strong> im Sinne
von Art. 22 DSGVO, da:
</p>
<ul className="text-sm text-slate-600 list-disc list-inside mt-2 space-y-1">
<li>Regelauswertung ist keine rechtlich bindende Entscheidung</li>
<li>Alle kritischen Faelle werden menschlich geprueft (E1-E3)</li>
<li>BLOCK-Entscheidungen erfordern immer menschliche Freigabe</li>
<li>Betroffene haben Anfechtungsmoeglichkeit ueber Eskalation</li>
</ul>
</div>
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<h4 className="font-medium text-green-800 mb-2">5. Technische und Organisatorische Massnahmen</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<strong className="text-green-700">Vertraulichkeit</strong>
<ul className="text-green-700 list-disc list-inside mt-1">
<li>RBAC mit Tenant-Isolation</li>
<li>TLS 1.3 Verschluesselung</li>
<li>AES-256 at rest</li>
</ul>
</div>
<div>
<strong className="text-green-700">Integritaet</strong>
<ul className="text-green-700 list-disc list-inside mt-1">
<li>Unveraenderlicher Audit-Trail</li>
<li>Policy-Versionierung</li>
<li>Input-Validierung</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div className="flex gap-4">
<a
href="/api/ucca/documentation/architecture.md"
download
className="flex-1 p-4 bg-primary-50 rounded-lg border border-primary-200 text-center hover:bg-primary-100"
>
<div className="text-2xl mb-2">📄</div>
<div className="font-medium text-primary-800">ARCHITECTURE.md herunterladen</div>
<div className="text-sm text-primary-600">Technische Dokumentation</div>
</a>
<a
href="/api/ucca/documentation/auditor.md"
download
className="flex-1 p-4 bg-green-50 rounded-lg border border-green-200 text-center hover:bg-green-100"
>
<div className="text-2xl mb-2">📋</div>
<div className="font-medium text-green-800">AUDITOR_DOCUMENTATION.md</div>
<div className="text-sm text-green-600">Art. 30 DSGVO konform</div>
</a>
</div>
</div>
)
const renderRulesTab = () => (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-800 text-lg">Regel-Katalog</h3>
<p className="text-sm text-slate-500">Policy Version: {policyVersion}</p>
</div>
<div className="text-sm text-slate-500">
{rules.length} Regeln insgesamt
</div>
</div>
{loading ? (
<div className="text-center py-8 text-slate-500">Lade Regeln...</div>
) : (
<div className="space-y-4">
{/* Group by category */}
{Array.from(new Set(rules.map(r => r.category))).map(category => (
<div key={category} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200">
<h4 className="font-medium text-slate-800">{category}</h4>
<p className="text-xs text-slate-500">
{rules.filter(r => r.category === category).length} Regeln
</p>
</div>
<div className="divide-y divide-slate-100">
{rules.filter(r => r.category === category).map(rule => (
<div key={rule.code} className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-slate-500">{rule.code}</span>
<span className={`text-xs px-2 py-0.5 rounded ${
rule.severity === 'BLOCK' ? 'bg-red-100 text-red-700' :
rule.severity === 'WARN' ? 'bg-yellow-100 text-yellow-700' :
'bg-blue-100 text-blue-700'
}`}>
{rule.severity}
</span>
</div>
<div className="font-medium text-slate-800 mt-1">{rule.title}</div>
<div className="text-sm text-slate-600 mt-1">{rule.description}</div>
{rule.gdpr_ref && (
<div className="text-xs text-slate-500 mt-2">{rule.gdpr_ref}</div>
)}
</div>
{rule.risk_add && (
<div className="text-sm font-medium text-red-600">
+{rule.risk_add}
</div>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)
const renderLegalCorpus = () => (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-800 text-lg mb-4">Legal RAG Corpus</h3>
<p className="text-slate-600 mb-4">
Das System verwendet einen semantischen Suchindex mit 2.274 Chunks aus 19 EU-Regulierungen
fuer rechtsgrundlagenbasierte Erklaerungen.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<h4 className="font-medium text-blue-800 mb-2">Indexierte Regulierungen</h4>
<ul className="text-sm text-blue-700 space-y-1">
<li>DSGVO - Datenschutz-Grundverordnung</li>
<li>AI Act - EU KI-Verordnung</li>
<li>NIS2 - Cybersicherheits-Richtlinie</li>
<li>CRA - Cyber Resilience Act</li>
<li>Data Act - Datengesetz</li>
<li>DSA/DMA - Digital Services/Markets Act</li>
<li>DPF - EU-US Data Privacy Framework</li>
<li>BSI-TR-03161 - Digitale Identitaeten</li>
</ul>
</div>
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<h4 className="font-medium text-green-800 mb-2">RAG-Funktionalitaet</h4>
<ul className="text-sm text-green-700 space-y-1">
<li>Hybride Suche (Dense + BM25)</li>
<li>Semantisches Chunking</li>
<li>Cross-Encoder Reranking</li>
<li>Artikel-Referenz-Extraktion</li>
<li>Mehrsprachig (DE/EN)</li>
</ul>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-800 mb-4">Verwendung im System</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-primary-100 text-primary-700 rounded-full flex items-center justify-center flex-shrink-0">
1
</div>
<div>
<div className="font-medium text-slate-800">Benutzer fordert Erklaerung an</div>
<div className="text-sm text-slate-600">
Nach der Bewertung kann eine LLM-basierte Erklaerung generiert werden.
</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-primary-100 text-primary-700 rounded-full flex items-center justify-center flex-shrink-0">
2
</div>
<div>
<div className="font-medium text-slate-800">Legal RAG Client sucht relevante Artikel</div>
<div className="text-sm text-slate-600">
Basierend auf den ausgeloesten Regeln werden passende Gesetzestexte gefunden.
</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-primary-100 text-primary-700 rounded-full flex items-center justify-center flex-shrink-0">
3
</div>
<div>
<div className="font-medium text-slate-800">LLM generiert Erklaerung mit Rechtsgrundlage</div>
<div className="text-sm text-slate-600">
Die Erklaerung referenziert konkrete Artikel aus DSGVO, AI Act etc.
</div>
</div>
</div>
</div>
</div>
</div>
)
// ============================================================================
// Tabs Configuration
// ============================================================================
const tabs: { id: DocTab; label: string; icon: string }[] = [
{ id: 'overview', label: 'Uebersicht', icon: '🏠' },
{ id: 'architecture', label: 'Architektur', icon: '🏗️' },
{ id: 'auditor', label: 'Fuer Auditoren', icon: '📋' },
{ id: 'rules', label: 'Regel-Katalog', icon: '📜' },
{ id: 'legal-corpus', label: 'Legal RAG', icon: '⚖️' },
]
// ============================================================================
// Main Render
// ============================================================================
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<PagePurpose
title="System-Dokumentation"
purpose="Transparente Dokumentation des UCCA-Systems fuer Entwickler, Auditoren und Datenschutzbeauftragte. Alle Regeln, Kontrollen und Architektur-Details sind hier einsehbar."
audience={['Entwickler', 'DSB', 'Externe Auditoren', 'Rechtsabteilung']}
gdprArticles={['Art. 30', 'Art. 32', 'Art. 35']}
collapsible={true}
defaultCollapsed={true}
/>
<Link
href="/dsgvo/advisory-board"
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 border border-slate-200 rounded-lg hover:bg-slate-50"
>
Zurueck zum Advisory Board
</Link>
</div>
{/* Tab Navigation */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="flex border-b border-slate-200 overflow-x-auto">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors ${
activeTab === tab.id
? 'text-primary-600 border-b-2 border-primary-600 bg-primary-50'
: 'text-slate-600 hover:text-slate-800 hover:bg-slate-50'
}`}
>
<span>{tab.icon}</span>
{tab.label}
</button>
))}
</div>
<div className="p-6">
{activeTab === 'overview' && renderOverview()}
{activeTab === 'architecture' && renderArchitecture()}
{activeTab === 'auditor' && renderAuditorInfo()}
{activeTab === 'rules' && renderRulesTab()}
{activeTab === 'legal-corpus' && renderLegalCorpus()}
</div>
</div>
</div>
)
}

View File

@@ -1,992 +0,0 @@
/**
* UCCA Legal Metadata - Kompositorisches Bewertungssystem
*
* Jedes Feld trägt seine eigene Rechtsgrundlage.
* Das Ergebnis ist die Aggregation aller ausgewählten Felder.
* Bei Problemen werden Lösungsvorschläge angezeigt.
*/
// ============================================================================
// Types
// ============================================================================
export interface LegalReference {
article: string // z.B. "Art. 9 DSGVO"
title: string // z.B. "Besondere Kategorien personenbezogener Daten"
relevance: string // Warum relevant für dieses Feld
}
export interface RequiredControl {
id: string
title: string
description: string
effort: 'low' | 'medium' | 'high'
}
export interface FieldMetadata {
// Identifikation
id: string
label: string
labelSimple: string // Einfache Sprache
// Rechtliche Einordnung
legalRefs: LegalReference[]
// Risikobewertung
riskScore: number // 0-30 pro Feld
severity: 'INFO' | 'WARN' | 'BLOCK'
// Erforderliche Maßnahmen wenn ausgewählt
requiredControls: string[]
// Erklärungen
explanation: string // Fachsprache
explanationSimple: string // Einfache Sprache
// Hinweis für Nutzer
userHint?: string
}
export interface ProblemSolution {
id: string
title: string
description: string
// Was ändert sich wenn Lösung akzeptiert wird
removes_fields?: string[] // Diese Felder werden "entschärft"
adds_controls?: string[] // Diese Kontrollen werden hinzugefügt
new_risk_score?: number // Neuer Risiko-Beitrag (meist 0)
effort: 'low' | 'medium' | 'high'
// Frage an das Team
team_question: string
}
export interface Problem {
id: string
title: string
description: string
severity: 'WARN' | 'BLOCK'
// Welche Feld-Kombination löst das Problem aus
triggered_by: {
all_of?: string[] // Alle müssen ausgewählt sein
any_of?: string[] // Mindestens eins muss ausgewählt sein
none_of?: string[] // Keins darf ausgewählt sein (z.B. fehlende Einwilligung)
}
// Rechtliche Grundlage
legalRefs: LegalReference[]
// Mögliche Lösungen
solutions: ProblemSolution[]
}
// ============================================================================
// Erforderliche Kontrollen / Maßnahmen
// ============================================================================
export const CONTROLS: Record<string, RequiredControl> = {
explicit_consent: {
id: 'explicit_consent',
title: 'Ausdrückliche Einwilligung',
description: 'Betroffene müssen aktiv und informiert einwilligen (Opt-in, keine vorausgefüllten Checkboxen).',
effort: 'medium',
},
parental_consent: {
id: 'parental_consent',
title: 'Einwilligung der Erziehungsberechtigten',
description: 'Bei Minderjährigen muss die Einwilligung der Eltern/Erziehungsberechtigten eingeholt werden.',
effort: 'high',
},
age_verification: {
id: 'age_verification',
title: 'Altersverifikation',
description: 'Mechanismus zur Prüfung des Alters der Nutzer implementieren.',
effort: 'medium',
},
dsfa: {
id: 'dsfa',
title: 'Datenschutz-Folgenabschätzung (DSFA)',
description: 'Formale DSFA nach Art. 35 DSGVO durchführen und dokumentieren.',
effort: 'high',
},
human_in_the_loop: {
id: 'human_in_the_loop',
title: 'Menschliche Überprüfung (HITL)',
description: 'Jede automatisierte Entscheidung muss von einem Menschen überprüft werden können.',
effort: 'medium',
},
contestation_right: {
id: 'contestation_right',
title: 'Anfechtungsrecht',
description: 'Betroffene müssen automatisierte Entscheidungen anfechten können.',
effort: 'low',
},
data_minimization: {
id: 'data_minimization',
title: 'Datenminimierung',
description: 'Nur die unbedingt notwendigen Daten erheben und verarbeiten.',
effort: 'low',
},
anonymization: {
id: 'anonymization',
title: 'Anonymisierung',
description: 'Personenbezogene Daten vor der Verarbeitung anonymisieren.',
effort: 'medium',
},
pseudonymization: {
id: 'pseudonymization',
title: 'Pseudonymisierung',
description: 'Direkte Identifikatoren durch Pseudonyme ersetzen.',
effort: 'medium',
},
encryption: {
id: 'encryption',
title: 'Verschlüsselung',
description: 'Daten bei Übertragung und Speicherung verschlüsseln.',
effort: 'low',
},
access_logging: {
id: 'access_logging',
title: 'Zugriffs-Protokollierung',
description: 'Alle Zugriffe auf personenbezogene Daten protokollieren.',
effort: 'low',
},
retention_policy: {
id: 'retention_policy',
title: 'Löschkonzept',
description: 'Automatische Löschung nach definierter Aufbewahrungsfrist.',
effort: 'medium',
},
scc: {
id: 'scc',
title: 'Standardvertragsklauseln (SCC)',
description: 'EU-Standardvertragsklauseln mit Drittland-Anbieter abschließen.',
effort: 'medium',
},
tia: {
id: 'tia',
title: 'Transfer Impact Assessment',
description: 'Bewertung der Datenschutzrisiken bei Drittlandtransfer.',
effort: 'high',
},
purpose_limitation: {
id: 'purpose_limitation',
title: 'Zweckbindung dokumentieren',
description: 'Verarbeitungszweck klar definieren und dokumentieren.',
effort: 'low',
},
transparency: {
id: 'transparency',
title: 'Transparenz-Information',
description: 'Betroffene über die Verarbeitung informieren (Datenschutzerklärung).',
effort: 'low',
},
pixelization: {
id: 'pixelization',
title: 'Verpixelung/Unkenntlichmachung',
description: 'Identifizierende Merkmale (Gesichter, Kennzeichen) automatisch verpixeln.',
effort: 'medium',
},
no_training: {
id: 'no_training',
title: 'Kein KI-Training mit Daten',
description: 'Daten dürfen nur für Inferenz, nicht für Training verwendet werden.',
effort: 'low',
},
}
// ============================================================================
// Feld-Metadaten: Datentypen
// ============================================================================
export const DATA_TYPE_METADATA: Record<string, FieldMetadata> = {
personal_data: {
id: 'personal_data',
label: 'Personenbezogene Daten',
labelSimple: 'Namen, E-Mails, Adressen',
legalRefs: [
{ article: 'Art. 4(1) DSGVO', title: 'Definition personenbezogener Daten', relevance: 'Grundlegende Definition' },
{ article: 'Art. 6 DSGVO', title: 'Rechtmäßigkeit der Verarbeitung', relevance: 'Rechtsgrundlage erforderlich' },
],
riskScore: 10,
severity: 'INFO',
requiredControls: ['purpose_limitation', 'transparency'],
explanation: 'Personenbezogene Daten erfordern eine Rechtsgrundlage nach Art. 6 DSGVO.',
explanationSimple: 'Wenn Sie Daten verarbeiten, mit denen man Personen identifizieren kann, brauchen Sie einen guten Grund dafür.',
},
article_9_data: {
id: 'article_9_data',
label: 'Besondere Kategorien (Art. 9)',
labelSimple: 'Gesundheit, Religion, politische Meinung',
legalRefs: [
{ article: 'Art. 9 DSGVO', title: 'Besondere Kategorien personenbezogener Daten', relevance: 'Grundsätzliches Verarbeitungsverbot' },
{ article: 'Art. 9(2) DSGVO', title: 'Ausnahmen vom Verbot', relevance: 'Ausdrückliche Einwilligung oder andere Ausnahme erforderlich' },
],
riskScore: 25,
severity: 'WARN',
requiredControls: ['explicit_consent', 'dsfa', 'encryption'],
explanation: 'Besondere Kategorien personenbezogener Daten sind grundsätzlich verboten. Ausnahmen nur bei ausdrücklicher Einwilligung oder anderen Art. 9(2) Gründen.',
explanationSimple: 'Gesundheitsdaten, religiöse Überzeugungen und ähnlich sensible Daten dürfen nur in Ausnahmefällen verarbeitet werden.',
userHint: '⚠️ Hohes Risiko - DSFA wahrscheinlich erforderlich',
},
minor_data: {
id: 'minor_data',
label: 'Daten von Minderjährigen',
labelSimple: 'Daten von Kindern/Jugendlichen (unter 18)',
legalRefs: [
{ article: 'Art. 8 DSGVO', title: 'Bedingungen für die Einwilligung eines Kindes', relevance: 'Besondere Anforderungen an Einwilligung' },
{ article: 'ErwGr. 38', title: 'Besonderer Schutz für Kinder', relevance: 'Kinder verdienen besonderen Schutz' },
],
riskScore: 20,
severity: 'WARN',
requiredControls: ['parental_consent', 'age_verification', 'data_minimization'],
explanation: 'Daten von Minderjährigen erfordern besondere Schutzmaßnahmen. Bei Onlinediensten: Einwilligung ab 16 Jahren (in DE), darunter Elterneinwilligung.',
explanationSimple: 'Bei Kindern und Jugendlichen gelten strengere Regeln. Oft müssen die Eltern zustimmen.',
userHint: '👶 Besonderer Schutz für Minderjährige erforderlich',
},
license_plates: {
id: 'license_plates',
label: 'KFZ-Kennzeichen',
labelSimple: 'Auto-Kennzeichen',
legalRefs: [
{ article: 'Art. 4(1) DSGVO', title: 'Personenbezogene Daten', relevance: 'Kennzeichen ermöglichen Identifikation des Halters' },
{ article: 'Art. 6 DSGVO', title: 'Rechtmäßigkeit', relevance: 'Rechtsgrundlage für Verarbeitung erforderlich' },
],
riskScore: 15,
severity: 'WARN',
requiredControls: ['purpose_limitation', 'retention_policy'],
explanation: 'KFZ-Kennzeichen sind personenbezogene Daten, da sie die Identifikation des Halters ermöglichen.',
explanationSimple: 'Über ein Kennzeichen kann man den Fahrzeughalter herausfinden - daher sind es persönliche Daten.',
userHint: '🚗 Kennzeichen = personenbezogene Daten',
},
images: {
id: 'images',
label: 'Bilder von Personen',
labelSimple: 'Fotos mit erkennbaren Gesichtern',
legalRefs: [
{ article: 'Art. 4(14) DSGVO', title: 'Biometrische Daten', relevance: 'Gesichtsbilder können biometrische Daten sein' },
{ article: '§ 22 KUG', title: 'Recht am eigenen Bild', relevance: 'Einwilligung für Bildveröffentlichung' },
],
riskScore: 15,
severity: 'WARN',
requiredControls: ['explicit_consent', 'purpose_limitation'],
explanation: 'Bilder von Personen sind personenbezogene Daten. Bei Gesichtserkennung: biometrische Daten (Art. 9).',
explanationSimple: 'Fotos von Menschen brauchen deren Erlaubnis. Gesichtserkennung hat noch strengere Regeln.',
},
audio: {
id: 'audio',
label: 'Sprachaufnahmen',
labelSimple: 'Gespräche, Telefonate, Sprachnachrichten',
legalRefs: [
{ article: 'Art. 4(1) DSGVO', title: 'Personenbezogene Daten', relevance: 'Stimme ermöglicht Identifikation' },
{ article: '§ 201 StGB', title: 'Vertraulichkeit des Wortes', relevance: 'Heimliche Aufnahmen sind strafbar' },
],
riskScore: 15,
severity: 'WARN',
requiredControls: ['explicit_consent', 'transparency'],
explanation: 'Sprachaufnahmen sind personenbezogene Daten. Heimliche Aufnahmen können strafbar sein.',
explanationSimple: 'Gespräche aufzunehmen erfordert die Zustimmung aller Beteiligten.',
userHint: '🎤 Aufnahme nur mit Wissen der Betroffenen',
},
location_data: {
id: 'location_data',
label: 'Standortdaten',
labelSimple: 'GPS, Aufenthaltsorte, Bewegungsdaten',
legalRefs: [
{ article: 'Art. 4(1) DSGVO', title: 'Personenbezogene Daten', relevance: 'Standorte ermöglichen Profilbildung' },
{ article: 'ErwGr. 75', title: 'Risiken für Betroffene', relevance: 'Bewegungsprofile sind risikobehaftet' },
],
riskScore: 20,
severity: 'WARN',
requiredControls: ['explicit_consent', 'data_minimization', 'retention_policy'],
explanation: 'Standortdaten ermöglichen detaillierte Bewegungsprofile. Hohes Risiko für Betroffene.',
explanationSimple: 'Standortdaten zeigen, wo jemand wann war. Das ist sehr persönlich.',
},
biometric_data: {
id: 'biometric_data',
label: 'Biometrische Daten',
labelSimple: 'Fingerabdrücke, Gesichtserkennung',
legalRefs: [
{ article: 'Art. 9(1) DSGVO', title: 'Besondere Kategorien', relevance: 'Biometrische Daten zur Identifikation' },
{ article: 'Art. 4(14) DSGVO', title: 'Definition biometrischer Daten', relevance: 'Technische Verarbeitung physischer Merkmale' },
],
riskScore: 30,
severity: 'WARN',
requiredControls: ['explicit_consent', 'dsfa', 'encryption', 'access_logging'],
explanation: 'Biometrische Daten zur eindeutigen Identifikation fallen unter Art. 9 DSGVO.',
explanationSimple: 'Fingerabdrücke und Gesichtserkennung sind besonders geschützt.',
userHint: '⚠️ Art. 9 DSGVO - Besondere Kategorie',
},
financial_data: {
id: 'financial_data',
label: 'Finanzdaten',
labelSimple: 'Gehälter, Kontodaten, Kreditwürdigkeit',
legalRefs: [
{ article: 'Art. 6 DSGVO', title: 'Rechtmäßigkeit', relevance: 'Rechtsgrundlage erforderlich' },
{ article: '§ 31 BDSG', title: 'Schutz des Wirtschaftsverkehrs', relevance: 'Scoring-Regelungen' },
],
riskScore: 15,
severity: 'INFO',
requiredControls: ['encryption', 'access_logging', 'purpose_limitation'],
explanation: 'Finanzdaten erfordern besondere Sicherheitsmaßnahmen.',
explanationSimple: 'Kontodaten und Gehälter müssen besonders geschützt werden.',
},
employee_data: {
id: 'employee_data',
label: 'Mitarbeiterdaten',
labelSimple: 'Personalakten, Bewertungen, Gehälter',
legalRefs: [
{ article: '§ 26 BDSG', title: 'Beschäftigtendatenschutz', relevance: 'Besondere Regelungen für Arbeitsverhältnisse' },
{ article: 'Art. 88 DSGVO', title: 'Datenverarbeitung im Beschäftigungskontext', relevance: 'Nationale Regelungen möglich' },
],
riskScore: 15,
severity: 'INFO',
requiredControls: ['purpose_limitation', 'access_logging', 'transparency'],
explanation: 'Beschäftigtendaten unterliegen dem § 26 BDSG. Betriebsrat ggf. einzubinden.',
explanationSimple: 'Bei Mitarbeiterdaten gelten besondere Regeln. Der Betriebsrat hat Mitspracherecht.',
userHint: '👔 Betriebsrat einbinden',
},
customer_data: {
id: 'customer_data',
label: 'Kundendaten',
labelSimple: 'Bestellungen, Kontaktdaten, Kaufhistorie',
legalRefs: [
{ article: 'Art. 6(1)(b) DSGVO', title: 'Vertragserfüllung', relevance: 'Oft Rechtsgrundlage für Kundendaten' },
],
riskScore: 10,
severity: 'INFO',
requiredControls: ['transparency', 'retention_policy'],
explanation: 'Kundendaten können oft auf Basis der Vertragserfüllung verarbeitet werden.',
explanationSimple: 'Kundendaten brauchen Sie für Bestellungen - das ist meist erlaubt.',
},
public_data: {
id: 'public_data',
label: 'Nur öffentliche Daten',
labelSimple: 'Keine personenbezogenen Daten',
legalRefs: [
{ article: 'ErwGr. 26', title: 'Anonyme Informationen', relevance: 'DSGVO gilt nicht für anonyme Daten' },
],
riskScore: 0,
severity: 'INFO',
requiredControls: [],
explanation: 'Wenn keine personenbezogenen Daten verarbeitet werden, ist die DSGVO nicht anwendbar.',
explanationSimple: 'Ohne persönliche Daten gelten die strengen Regeln nicht.',
userHint: '✅ Geringes Risiko',
},
}
// ============================================================================
// Feld-Metadaten: Automatisierung
// ============================================================================
export const AUTOMATION_METADATA: Record<string, FieldMetadata> = {
assistive: {
id: 'assistive',
label: 'Assistierend (KI macht Vorschläge)',
labelSimple: 'KI macht Vorschläge, Mensch entscheidet',
legalRefs: [],
riskScore: 0,
severity: 'INFO',
requiredControls: [],
explanation: 'Bei assistierender KI bleibt der Mensch Entscheider. Art. 22 DSGVO nicht betroffen.',
explanationSimple: 'Die KI schlägt vor, Sie entscheiden. Das ist die sicherste Variante.',
userHint: '✅ Empfohlen',
},
semi_automated: {
id: 'semi_automated',
label: 'Teilautomatisiert (Mensch prüft)',
labelSimple: 'KI filtert vor, Mensch prüft',
legalRefs: [
{ article: 'ErwGr. 71', title: 'Profiling und automatisierte Entscheidungen', relevance: 'Menschliche Überprüfung empfohlen' },
],
riskScore: 10,
severity: 'INFO',
requiredControls: ['human_in_the_loop'],
explanation: 'Teilautomatisierung mit menschlicher Kontrolle ist meist unproblematisch.',
explanationSimple: 'Die KI arbeitet vor, aber ein Mensch schaut drüber.',
},
fully_automated: {
id: 'fully_automated',
label: 'Vollautomatisiert (keine menschliche Prüfung)',
labelSimple: 'KI entscheidet alleine',
legalRefs: [
{ article: 'Art. 22(1) DSGVO', title: 'Automatisierte Einzelentscheidungen', relevance: 'Grundsätzliches Verbot bei rechtlicher Wirkung' },
{ article: 'Art. 22(2) DSGVO', title: 'Ausnahmen', relevance: 'Erlaubt bei Vertrag, Gesetz oder Einwilligung' },
],
riskScore: 25,
severity: 'WARN',
requiredControls: ['human_in_the_loop', 'contestation_right', 'transparency'],
explanation: 'Vollautomatisierte Entscheidungen mit rechtlicher Wirkung sind nach Art. 22 DSGVO grundsätzlich verboten.',
explanationSimple: 'Wenn die KI alleine entscheidet und das Auswirkungen auf Menschen hat, ist das problematisch.',
userHint: '⚠️ Art. 22 DSGVO beachten',
},
}
// ============================================================================
// Feld-Metadaten: Zweck
// ============================================================================
export const PURPOSE_METADATA: Record<string, FieldMetadata> = {
customer_support: {
id: 'customer_support',
label: 'Kundenservice',
labelSimple: 'Fragen beantworten, Hilfe anbieten',
legalRefs: [
{ article: 'Art. 6(1)(b) DSGVO', title: 'Vertragserfüllung', relevance: 'Oft Rechtsgrundlage' },
],
riskScore: 5,
severity: 'INFO',
requiredControls: ['transparency'],
explanation: 'Kundenservice kann meist auf Vertragserfüllung gestützt werden.',
explanationSimple: 'Kunden zu helfen ist meist erlaubt.',
},
evaluation_scoring: {
id: 'evaluation_scoring',
label: 'Bewertung/Scoring von Personen',
labelSimple: 'Personen bewerten, Punkte vergeben, einstufen',
legalRefs: [
{ article: 'Art. 22 DSGVO', title: 'Automatisierte Einzelentscheidungen', relevance: 'Bei automatischem Scoring relevant' },
{ article: '§ 31 BDSG', title: 'Scoring', relevance: 'Besondere Regelungen für Scoring' },
],
riskScore: 20,
severity: 'WARN',
requiredControls: ['transparency', 'contestation_right', 'dsfa'],
explanation: 'Scoring von Personen unterliegt strengen Anforderungen. Bei automatisierten Entscheidungen: Art. 22.',
explanationSimple: 'Menschen zu bewerten oder einzustufen ist sensibel. Betroffene müssen das anfechten können.',
userHint: '⚠️ Scoring ist risikobehaftet',
},
decision_making: {
id: 'decision_making',
label: 'Automatisierte Entscheidungen',
labelSimple: 'Genehmigungen, Ablehnungen, Zugang',
legalRefs: [
{ article: 'Art. 22 DSGVO', title: 'Automatisierte Einzelentscheidungen', relevance: 'Kernartikel für automatisierte Entscheidungen' },
],
riskScore: 25,
severity: 'WARN',
requiredControls: ['human_in_the_loop', 'contestation_right', 'transparency'],
explanation: 'Automatisierte Entscheidungen mit rechtlicher Wirkung erfordern besondere Schutzmaßnahmen.',
explanationSimple: 'Wenn die KI über Menschen entscheidet (Kredit, Bewerbung, etc.), gelten strenge Regeln.',
userHint: '⚠️ Art. 22 DSGVO prüfen',
},
profiling: {
id: 'profiling',
label: 'Profiling',
labelSimple: 'Personenprofile erstellen, Verhalten analysieren',
legalRefs: [
{ article: 'Art. 4(4) DSGVO', title: 'Definition Profiling', relevance: 'Automatisierte Verarbeitung zur Bewertung' },
{ article: 'Art. 22 DSGVO', title: 'Automatisierte Entscheidungen einschl. Profiling', relevance: 'Bei Entscheidungen aufgrund von Profiling' },
],
riskScore: 20,
severity: 'WARN',
requiredControls: ['transparency', 'dsfa'],
explanation: 'Profiling ist die automatisierte Bewertung persönlicher Aspekte. Erfordert Transparenz und oft DSFA.',
explanationSimple: 'Profile über Menschen zu erstellen erfordert besondere Vorsicht.',
},
marketing: {
id: 'marketing',
label: 'Marketing/Werbung',
labelSimple: 'Werbung, Newsletter, Kampagnen',
legalRefs: [
{ article: 'Art. 6(1)(f) DSGVO', title: 'Berechtigte Interessen', relevance: 'Direktwerbung kann berechtigtes Interesse sein' },
{ article: '§ 7 UWG', title: 'Unzumutbare Belästigung', relevance: 'E-Mail-Werbung nur mit Einwilligung' },
],
riskScore: 10,
severity: 'INFO',
requiredControls: ['explicit_consent', 'transparency'],
explanation: 'E-Mail-Marketing erfordert i.d.R. Einwilligung (Opt-in).',
explanationSimple: 'Für Werbe-E-Mails brauchen Sie die Erlaubnis der Empfänger.',
},
analytics: {
id: 'analytics',
label: 'Analyse/Statistik',
labelSimple: 'Auswertungen, Berichte, Trends',
legalRefs: [
{ article: 'Art. 6(1)(f) DSGVO', title: 'Berechtigte Interessen', relevance: 'Analysen oft auf berechtigtes Interesse stützbar' },
],
riskScore: 5,
severity: 'INFO',
requiredControls: ['data_minimization'],
explanation: 'Statistische Analysen sind oft auf berechtigtes Interesse stützbar, wenn datenminimiert.',
explanationSimple: 'Auswertungen für interne Zwecke sind meist unproblematisch.',
},
research: {
id: 'research',
label: 'Forschung',
labelSimple: 'Wissenschaftliche Untersuchungen',
legalRefs: [
{ article: 'Art. 89 DSGVO', title: 'Garantien für Forschungszwecke', relevance: 'Privilegierung von Forschung' },
],
riskScore: 5,
severity: 'INFO',
requiredControls: ['data_minimization', 'pseudonymization'],
explanation: 'Forschung genießt gewisse Privilegien, erfordert aber Schutzmaßnahmen.',
explanationSimple: 'Forschung hat Sonderregeln, wenn die Daten geschützt werden.',
},
}
// ============================================================================
// Feld-Metadaten: Hosting
// ============================================================================
export const HOSTING_METADATA: Record<string, FieldMetadata> = {
eu: {
id: 'eu',
label: 'EU/EWR',
labelSimple: 'In Deutschland oder EU',
legalRefs: [],
riskScore: 0,
severity: 'INFO',
requiredControls: [],
explanation: 'Hosting in der EU ist datenschutzrechtlich unproblematisch.',
explanationSimple: 'Daten in Europa zu speichern ist die einfachste Lösung.',
userHint: '✅ Empfohlen',
},
third_country: {
id: 'third_country',
label: 'Drittland (außerhalb EU)',
labelSimple: 'USA, Schweiz, UK, andere',
legalRefs: [
{ article: 'Art. 44 DSGVO', title: 'Grundsatz für Übermittlung', relevance: 'Besondere Anforderungen an Drittlandtransfer' },
{ article: 'Art. 46 DSGVO', title: 'Geeignete Garantien', relevance: 'SCC oder andere Garantien erforderlich' },
],
riskScore: 15,
severity: 'WARN',
requiredControls: ['scc', 'tia'],
explanation: 'Drittlandtransfer erfordert zusätzliche Garantien (z.B. SCC) und ein Transfer Impact Assessment.',
explanationSimple: 'Daten außerhalb der EU zu speichern braucht extra Verträge und Prüfungen.',
userHint: '⚠️ Zusätzliche Maßnahmen erforderlich',
},
on_prem: {
id: 'on_prem',
label: 'On-Premise (eigene Server)',
labelSimple: 'Auf unseren eigenen Servern',
legalRefs: [],
riskScore: 0,
severity: 'INFO',
requiredControls: ['encryption'],
explanation: 'On-Premise bietet volle Kontrolle, erfordert aber eigene Sicherheitsmaßnahmen.',
explanationSimple: 'Eigene Server geben volle Kontrolle, aber Sie sind für die Sicherheit verantwortlich.',
},
}
// ============================================================================
// Feld-Metadaten: Modell-Nutzung
// ============================================================================
export const MODEL_USAGE_METADATA: Record<string, FieldMetadata> = {
rag: {
id: 'rag',
label: 'RAG (Dokumentensuche)',
labelSimple: 'KI durchsucht meine Dokumente',
legalRefs: [],
riskScore: 5,
severity: 'INFO',
requiredControls: [],
explanation: 'RAG-Ansätze sind datenschutzfreundlich, da keine Daten ins Modell fließen.',
explanationSimple: 'Die KI sucht in Ihren Dokumenten, lernt aber nicht daraus. Das ist sicher.',
userHint: '✅ Datenschutzfreundlich',
},
inference: {
id: 'inference',
label: 'Nur Inferenz',
labelSimple: 'KI nur nutzen, ohne eigene Daten',
legalRefs: [],
riskScore: 0,
severity: 'INFO',
requiredControls: [],
explanation: 'Reine Inferenz ohne Datenspeicherung ist unproblematisch.',
explanationSimple: 'Die KI nutzen ohne eigene Daten einzugeben ist sicher.',
userHint: '✅ Geringes Risiko',
},
finetune: {
id: 'finetune',
label: 'Fine-Tuning',
labelSimple: 'KI mit meinen Daten anpassen',
legalRefs: [
{ article: 'Art. 5(1)(b) DSGVO', title: 'Zweckbindung', relevance: 'Training ist neuer Zweck' },
],
riskScore: 20,
severity: 'WARN',
requiredControls: ['explicit_consent', 'purpose_limitation', 'no_training'],
explanation: 'Fine-Tuning mit personenbezogenen Daten erfordert eigene Rechtsgrundlage.',
explanationSimple: 'Wenn die KI aus Ihren Daten lernt, ist das ein eigener Verarbeitungsschritt.',
userHint: '⚠️ Eigene Rechtsgrundlage erforderlich',
},
training: {
id: 'training',
label: 'Vollständiges Training',
labelSimple: 'KI komplett mit meinen Daten trainieren',
legalRefs: [
{ article: 'Art. 5(1)(b) DSGVO', title: 'Zweckbindung', relevance: 'Training ist neuer Zweck' },
{ article: 'Art. 6 DSGVO', title: 'Rechtmäßigkeit', relevance: 'Eigene Rechtsgrundlage erforderlich' },
],
riskScore: 25,
severity: 'WARN',
requiredControls: ['explicit_consent', 'dsfa', 'purpose_limitation'],
explanation: 'KI-Training mit personenbezogenen Daten ist ein eigenständiger Verarbeitungszweck.',
explanationSimple: 'Die KI komplett mit Ihren Daten zu trainieren braucht klare Einwilligung.',
userHint: '⚠️ Hohes Risiko',
},
}
// ============================================================================
// Probleme & Lösungen
// ============================================================================
export const PROBLEMS: Problem[] = [
// KFZ-Kennzeichen ohne Einwilligung
{
id: 'license_plates_no_consent',
title: 'KFZ-Kennzeichen ohne Einwilligung',
description: 'Sie möchten KFZ-Kennzeichen verarbeiten, aber haben keine Einwilligung der Fahrzeughalter.',
severity: 'BLOCK',
triggered_by: {
all_of: ['license_plates'],
none_of: ['explicit_consent_obtained'],
},
legalRefs: [
{ article: 'Art. 6 DSGVO', title: 'Rechtmäßigkeit', relevance: 'Keine Rechtsgrundlage vorhanden' },
],
solutions: [
{
id: 'pixelize_plates',
title: 'Kennzeichen automatisch verpixeln',
description: 'Die Kennzeichen werden vor der Speicherung automatisch unkenntlich gemacht. Dadurch sind es keine personenbezogenen Daten mehr.',
removes_fields: ['license_plates'],
adds_controls: ['pixelization'],
new_risk_score: 0,
effort: 'medium',
team_question: 'Ist das Projekt auch mit verpixelten Kennzeichen (nicht lesbar) sinnvoll?',
},
{
id: 'obtain_consent',
title: 'Einwilligung einholen',
description: 'Die Fahrzeughalter um Einwilligung bitten (z.B. bei Parkhausbetreibern mit Dauerparker-Verträgen).',
adds_controls: ['explicit_consent'],
new_risk_score: 10,
effort: 'high',
team_question: 'Können Sie die Einwilligung der Fahrzeughalter einholen?',
},
],
},
// Gesichtserkennung ohne Einwilligung
{
id: 'biometrics_no_consent',
title: 'Biometrische Daten ohne Einwilligung',
description: 'Sie möchten biometrische Daten (z.B. Gesichtserkennung) verarbeiten, aber haben keine ausdrückliche Einwilligung.',
severity: 'BLOCK',
triggered_by: {
all_of: ['biometric_data'],
none_of: ['explicit_consent_obtained'],
},
legalRefs: [
{ article: 'Art. 9(1) DSGVO', title: 'Verarbeitungsverbot', relevance: 'Biometrische Daten sind besondere Kategorie' },
{ article: 'Art. 9(2)(a) DSGVO', title: 'Ausdrückliche Einwilligung', relevance: 'Einwilligung als Ausnahme' },
],
solutions: [
{
id: 'anonymize_faces',
title: 'Gesichter automatisch verpixeln/anonymisieren',
description: 'Gesichter werden vor der Speicherung automatisch unkenntlich gemacht.',
removes_fields: ['biometric_data'],
adds_controls: ['pixelization'],
new_risk_score: 0,
effort: 'medium',
team_question: 'Funktioniert Ihr Projekt auch ohne erkennbare Gesichter?',
},
{
id: 'explicit_biometric_consent',
title: 'Ausdrückliche Einwilligung einholen',
description: 'Betroffene müssen aktiv und informiert in die Gesichtserkennung einwilligen.',
adds_controls: ['explicit_consent', 'dsfa'],
new_risk_score: 20,
effort: 'high',
team_question: 'Können Sie eine ausdrückliche Einwilligung aller Betroffenen sicherstellen?',
},
],
},
// Minderjährige + automatisiertes Scoring
{
id: 'minor_automated_scoring',
title: 'Automatisiertes Scoring von Minderjährigen',
description: 'Sie möchten Minderjährige automatisiert bewerten oder einstufen. Das ist besonders problematisch.',
severity: 'BLOCK',
triggered_by: {
all_of: ['minor_data', 'evaluation_scoring', 'fully_automated'],
},
legalRefs: [
{ article: 'Art. 22(1) DSGVO', title: 'Verbot automatisierter Entscheidungen', relevance: 'Grundsätzliches Verbot' },
{ article: 'Art. 8 DSGVO', title: 'Schutz von Kindern', relevance: 'Besonderer Schutz für Minderjährige' },
],
solutions: [
{
id: 'add_human_review',
title: 'Menschliche Überprüfung einführen',
description: 'Jede Bewertung wird von einem Menschen geprüft bevor sie wirksam wird.',
removes_fields: ['fully_automated'],
adds_controls: ['human_in_the_loop'],
new_risk_score: 15,
effort: 'medium',
team_question: 'Können Sie sicherstellen, dass ein Mensch jede Bewertung prüft?',
},
{
id: 'remove_scoring',
title: 'Auf Scoring verzichten',
description: 'Statt Scoring nur informative Auswertungen ohne Entscheidungscharakter.',
removes_fields: ['evaluation_scoring'],
new_risk_score: 10,
effort: 'low',
team_question: 'Funktioniert Ihr Projekt auch ohne Bewertung/Scoring der Minderjährigen?',
},
],
},
// Drittland + sensible Daten
{
id: 'third_country_sensitive',
title: 'Sensible Daten im Drittland',
description: 'Sie möchten besonders sensible Daten außerhalb der EU verarbeiten. Das erfordert umfangreiche Schutzmaßnahmen.',
severity: 'WARN',
triggered_by: {
all_of: ['third_country'],
any_of: ['article_9_data', 'biometric_data', 'minor_data'],
},
legalRefs: [
{ article: 'Art. 44 DSGVO', title: 'Drittlandtransfer', relevance: 'Besondere Anforderungen' },
{ article: 'Art. 9 DSGVO', title: 'Sensible Daten', relevance: 'Zusätzlicher Schutz erforderlich' },
],
solutions: [
{
id: 'move_to_eu',
title: 'Hosting in der EU',
description: 'Wählen Sie einen Anbieter mit Rechenzentren in der EU.',
removes_fields: ['third_country'],
new_risk_score: 0,
effort: 'medium',
team_question: 'Können Sie zu einem EU-Anbieter wechseln?',
},
{
id: 'implement_safeguards',
title: 'Umfangreiche Schutzmaßnahmen implementieren',
description: 'SCC, TIA, zusätzliche technische Maßnahmen implementieren.',
adds_controls: ['scc', 'tia', 'encryption'],
new_risk_score: 15,
effort: 'high',
team_question: 'Können Sie die erforderlichen Verträge und Maßnahmen umsetzen?',
},
],
},
// KI-Training mit personenbezogenen Daten
{
id: 'training_with_pii',
title: 'KI-Training mit personenbezogenen Daten',
description: 'Sie möchten ein KI-Modell mit personenbezogenen Daten trainieren. Das erfordert besondere Rechtsgrundlagen.',
severity: 'WARN',
triggered_by: {
all_of: ['training'],
any_of: ['personal_data', 'article_9_data', 'employee_data', 'customer_data'],
},
legalRefs: [
{ article: 'Art. 5(1)(b) DSGVO', title: 'Zweckbindung', relevance: 'Training ist eigener Zweck' },
{ article: 'Art. 6 DSGVO', title: 'Rechtmäßigkeit', relevance: 'Eigene Rechtsgrundlage erforderlich' },
],
solutions: [
{
id: 'use_rag_instead',
title: 'RAG statt Training verwenden',
description: 'Statt Training: Dokumente in Vektordatenbank ablegen und bei Anfragen durchsuchen.',
removes_fields: ['training'],
new_risk_score: 5,
effort: 'low',
team_question: 'Reicht es, wenn die KI Ihre Dokumente durchsuchen kann statt daraus zu lernen?',
},
{
id: 'anonymize_training_data',
title: 'Trainingsdaten anonymisieren',
description: 'Personenbezogene Daten vor dem Training vollständig anonymisieren.',
adds_controls: ['anonymization'],
new_risk_score: 5,
effort: 'high',
team_question: 'Können die Trainingsdaten vor dem Training anonymisiert werden?',
},
{
id: 'get_training_consent',
title: 'Einwilligung für Training einholen',
description: 'Betroffene explizit um Einwilligung für das KI-Training bitten.',
adds_controls: ['explicit_consent', 'dsfa'],
new_risk_score: 15,
effort: 'high',
team_question: 'Können Sie die Einwilligung aller Betroffenen für das KI-Training einholen?',
},
],
},
]
// ============================================================================
// Hilfsfunktionen
// ============================================================================
/**
* Aggregiert alle ausgewählten Felder und berechnet das Ergebnis
*/
export function evaluateSelection(selection: {
dataTypes: string[]
automation: string
purposes: string[]
hosting: string
modelUsage: string[]
acceptedSolutions: string[]
}): {
totalRiskScore: number
allLegalRefs: LegalReference[]
allRequiredControls: string[]
problems: Problem[]
severity: 'INFO' | 'WARN' | 'BLOCK'
} {
const allLegalRefs: LegalReference[] = []
const allRequiredControls: Set<string> = new Set()
let totalRiskScore = 0
let maxSeverity: 'INFO' | 'WARN' | 'BLOCK' = 'INFO'
// Aggregiere Datentypen
for (const dt of selection.dataTypes) {
const meta = DATA_TYPE_METADATA[dt]
if (meta) {
totalRiskScore += meta.riskScore
allLegalRefs.push(...meta.legalRefs)
meta.requiredControls.forEach(c => allRequiredControls.add(c))
if (meta.severity === 'BLOCK') maxSeverity = 'BLOCK'
else if (meta.severity === 'WARN' && maxSeverity !== 'BLOCK') maxSeverity = 'WARN'
}
}
// Aggregiere Automatisierung
const autoMeta = AUTOMATION_METADATA[selection.automation]
if (autoMeta) {
totalRiskScore += autoMeta.riskScore
allLegalRefs.push(...autoMeta.legalRefs)
autoMeta.requiredControls.forEach(c => allRequiredControls.add(c))
if (autoMeta.severity === 'BLOCK') maxSeverity = 'BLOCK'
else if (autoMeta.severity === 'WARN' && maxSeverity !== 'BLOCK') maxSeverity = 'WARN'
}
// Aggregiere Zwecke
for (const p of selection.purposes) {
const meta = PURPOSE_METADATA[p]
if (meta) {
totalRiskScore += meta.riskScore
allLegalRefs.push(...meta.legalRefs)
meta.requiredControls.forEach(c => allRequiredControls.add(c))
if (meta.severity === 'BLOCK') maxSeverity = 'BLOCK'
else if (meta.severity === 'WARN' && maxSeverity !== 'BLOCK') maxSeverity = 'WARN'
}
}
// Aggregiere Hosting
const hostMeta = HOSTING_METADATA[selection.hosting]
if (hostMeta) {
totalRiskScore += hostMeta.riskScore
allLegalRefs.push(...hostMeta.legalRefs)
hostMeta.requiredControls.forEach(c => allRequiredControls.add(c))
if (hostMeta.severity === 'BLOCK') maxSeverity = 'BLOCK'
else if (hostMeta.severity === 'WARN' && maxSeverity !== 'BLOCK') maxSeverity = 'WARN'
}
// Aggregiere Model Usage
for (const mu of selection.modelUsage) {
const meta = MODEL_USAGE_METADATA[mu]
if (meta) {
totalRiskScore += meta.riskScore
allLegalRefs.push(...meta.legalRefs)
meta.requiredControls.forEach(c => allRequiredControls.add(c))
if (meta.severity === 'BLOCK') maxSeverity = 'BLOCK'
else if (meta.severity === 'WARN' && maxSeverity !== 'BLOCK') maxSeverity = 'WARN'
}
}
// Finde zutreffende Probleme
const allSelectedFields = [
...selection.dataTypes,
selection.automation,
...selection.purposes,
selection.hosting,
...selection.modelUsage,
]
const triggeredProblems = PROBLEMS.filter(problem => {
// Prüfe all_of: alle müssen ausgewählt sein
if (problem.triggered_by.all_of) {
if (!problem.triggered_by.all_of.every(f => allSelectedFields.includes(f))) {
return false
}
}
// Prüfe any_of: mindestens eins muss ausgewählt sein
if (problem.triggered_by.any_of) {
if (!problem.triggered_by.any_of.some(f => allSelectedFields.includes(f))) {
return false
}
}
// Prüfe none_of: keins darf ausgewählt sein (außer durch Lösung)
if (problem.triggered_by.none_of) {
const hasNoneOf = problem.triggered_by.none_of.some(f =>
allSelectedFields.includes(f) || selection.acceptedSolutions.includes(f)
)
if (hasNoneOf) {
return false
}
}
// Prüfe ob Problem durch akzeptierte Lösung gelöst wurde
const isSolved = problem.solutions.some(solution =>
selection.acceptedSolutions.includes(solution.id)
)
return !isSolved
})
// Probleme beeinflussen Severity
for (const problem of triggeredProblems) {
if (problem.severity === 'BLOCK') maxSeverity = 'BLOCK'
else if (problem.severity === 'WARN' && maxSeverity !== 'BLOCK') maxSeverity = 'WARN'
}
// Dedupliziere Legal Refs
const uniqueLegalRefs = allLegalRefs.filter((ref, index, self) =>
index === self.findIndex(r => r.article === ref.article)
)
return {
totalRiskScore: Math.min(totalRiskScore, 100),
allLegalRefs: uniqueLegalRefs,
allRequiredControls: Array.from(allRequiredControls),
problems: triggeredProblems,
severity: maxSeverity,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,648 +0,0 @@
'use client'
/**
* Consent Admin Panel
*
* Admin interface for managing:
* - Documents (AGB, Privacy, etc.)
* - Document Versions
* - Email Templates
* - GDPR Processes (Art. 15-21)
* - Statistics
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// API Proxy URL (avoids CORS issues)
const API_BASE = '/api/admin/consent'
type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats'
interface Document {
id: string
type: string
name: string
description: string
mandatory: boolean
created_at: string
updated_at: string
}
interface Version {
id: string
document_id: string
version: string
language: string
title: string
content: string
status: string
created_at: string
}
export default function ConsentPage() {
const [activeTab, setActiveTab] = useState<Tab>('documents')
const [documents, setDocuments] = useState<Document[]>([])
const [versions, setVersions] = useState<Version[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedDocument, setSelectedDocument] = useState<string>('')
// Auth token (in production, get from auth context)
const [authToken, setAuthToken] = useState<string>('')
useEffect(() => {
// Get token from localStorage
const token = localStorage.getItem('bp_admin_token')
if (token) {
setAuthToken(token)
}
}, [])
useEffect(() => {
if (activeTab === 'documents') {
loadDocuments()
} else if (activeTab === 'versions' && selectedDocument) {
loadVersions(selectedDocument)
}
}, [activeTab, selectedDocument, authToken])
async function loadDocuments() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/documents`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setDocuments(data.documents || [])
} else {
const errorData = await res.json().catch(() => ({}))
setError(errorData.error || 'Fehler beim Laden der Dokumente')
}
} catch (err) {
setError('Verbindungsfehler zum Server')
} finally {
setLoading(false)
}
}
async function loadVersions(docId: string) {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/documents/${docId}/versions`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setVersions(data.versions || [])
} else {
const errorData = await res.json().catch(() => ({}))
setError(errorData.error || 'Fehler beim Laden der Versionen')
}
} catch (err) {
setError('Verbindungsfehler zum Server')
} finally {
setLoading(false)
}
}
const tabs: { id: Tab; label: string }[] = [
{ id: 'documents', label: 'Dokumente' },
{ id: 'versions', label: 'Versionen' },
{ id: 'emails', label: 'E-Mail Vorlagen' },
{ id: 'gdpr', label: 'DSGVO Prozesse' },
{ id: 'stats', label: 'Statistiken' },
]
// 16 Lifecycle Email Templates
const emailTemplates = [
// Onboarding
{ name: 'Willkommens-E-Mail', key: 'welcome', category: 'onboarding', description: 'Wird bei Registrierung versendet' },
{ name: 'E-Mail Bestaetigung', key: 'email_verification', category: 'onboarding', description: 'Bestaetigungslink fuer E-Mail-Adresse' },
{ name: 'Konto aktiviert', key: 'account_activated', category: 'onboarding', description: 'Bestaetigung der Kontoaktivierung' },
// Security
{ name: 'Passwort zuruecksetzen', key: 'password_reset', category: 'security', description: 'Link zum Zuruecksetzen des Passworts' },
{ name: 'Passwort geaendert', key: 'password_changed', category: 'security', description: 'Bestaetigung der Passwortaenderung' },
{ name: 'Neue Anmeldung', key: 'new_login', category: 'security', description: 'Benachrichtigung ueber Anmeldung von neuem Geraet' },
{ name: '2FA aktiviert', key: '2fa_enabled', category: 'security', description: 'Bestaetigung der 2FA-Aktivierung' },
// Consent & Legal
{ name: 'Neue Dokumentversion', key: 'new_document_version', category: 'consent', description: 'Info ueber neue Dokumentversion zur Zustimmung' },
{ name: 'Zustimmung erteilt', key: 'consent_given', category: 'consent', description: 'Bestaetigung der erteilten Zustimmung' },
{ name: 'Zustimmung widerrufen', key: 'consent_withdrawn', category: 'consent', description: 'Bestaetigung des Widerrufs' },
// Data Subject Rights (GDPR)
{ name: 'DSR Anfrage erhalten', key: 'dsr_received', category: 'gdpr', description: 'Bestaetigung des Eingangs einer DSGVO-Anfrage' },
{ name: 'Datenexport bereit', key: 'export_ready', category: 'gdpr', description: 'Benachrichtigung ueber fertigen Datenexport' },
{ name: 'Daten geloescht', key: 'data_deleted', category: 'gdpr', description: 'Bestaetigung der Datenloeschung' },
{ name: 'Daten berichtigt', key: 'data_rectified', category: 'gdpr', description: 'Bestaetigung der Datenberichtigung' },
// Account Lifecycle
{ name: 'Konto deaktiviert', key: 'account_deactivated', category: 'lifecycle', description: 'Konto wurde deaktiviert' },
{ name: 'Konto geloescht', key: 'account_deleted', category: 'lifecycle', description: 'Bestaetigung der Kontoloeschung' },
]
// GDPR Article 15-21 Processes
const gdprProcesses = [
{
article: '15',
title: 'Auskunftsrecht',
description: 'Recht auf Bestaetigung und Auskunft ueber verarbeitete personenbezogene Daten',
actions: ['Datenexport generieren', 'Verarbeitungszwecke auflisten', 'Empfaenger auflisten'],
sla: '30 Tage',
status: 'active'
},
{
article: '16',
title: 'Recht auf Berichtigung',
description: 'Recht auf Berichtigung unrichtiger personenbezogener Daten',
actions: ['Daten bearbeiten', 'Aenderungshistorie fuehren', 'Benachrichtigung senden'],
sla: '30 Tage',
status: 'active'
},
{
article: '17',
title: 'Recht auf Loeschung ("Vergessenwerden")',
description: 'Recht auf Loeschung personenbezogener Daten unter bestimmten Voraussetzungen',
actions: ['Loeschantrag pruefen', 'Daten loeschen', 'Aufbewahrungsfristen pruefen', 'Loeschbestaetigung senden'],
sla: '30 Tage',
status: 'active'
},
{
article: '18',
title: 'Recht auf Einschraenkung der Verarbeitung',
description: 'Recht auf Markierung von Daten zur eingeschraenkten Verarbeitung',
actions: ['Daten markieren', 'Verarbeitung einschraenken', 'Benachrichtigung bei Aufhebung'],
sla: '30 Tage',
status: 'active'
},
{
article: '19',
title: 'Mitteilungspflicht',
description: 'Pflicht zur Mitteilung von Berichtigung, Loeschung oder Einschraenkung an Empfaenger',
actions: ['Empfaenger ermitteln', 'Mitteilungen versenden', 'Protokollierung'],
sla: 'Unverzueglich',
status: 'active'
},
{
article: '20',
title: 'Recht auf Datenuebertragbarkeit',
description: 'Recht auf Erhalt der Daten in strukturiertem, maschinenlesbarem Format',
actions: ['JSON/CSV Export', 'API-Schnittstelle', 'Direkte Uebertragung'],
sla: '30 Tage',
status: 'active'
},
{
article: '21',
title: 'Widerspruchsrecht',
description: 'Recht auf Widerspruch gegen Verarbeitung aus berechtigtem Interesse oder Direktwerbung',
actions: ['Widerspruch erfassen', 'Verarbeitung stoppen', 'Marketing-Opt-out'],
sla: 'Unverzueglich',
status: 'active'
},
]
const emailCategories = [
{ key: 'onboarding', label: 'Onboarding', color: 'bg-blue-100 text-blue-700' },
{ key: 'security', label: 'Sicherheit', color: 'bg-red-100 text-red-700' },
{ key: 'consent', label: 'Zustimmung', color: 'bg-green-100 text-green-700' },
{ key: 'gdpr', label: 'DSGVO', color: 'bg-purple-100 text-purple-700' },
{ key: 'lifecycle', label: 'Lifecycle', color: 'bg-orange-100 text-orange-700' },
]
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Consent Verwaltung"
purpose="Verwalten Sie rechtliche Dokumente (AGB, Datenschutz, Cookie-Richtlinien) und deren Versionen. Jede Einwilligung eines Benutzers basiert auf diesen Dokumenten und muss nachvollziehbar sein."
audience={['DSB', 'Entwickler', 'Compliance Officer']}
gdprArticles={['Art. 7 (Einwilligung)', 'Art. 13/14 (Informationspflichten)']}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'DSR-Verwaltung', href: '/compliance/dsr', description: 'Datenschutzanfragen bearbeiten' },
{ name: 'DSGVO-Audit', href: '/compliance/audit', description: 'Audit-Dokumentation erstellen' },
{ name: 'Workflow', href: '/compliance/workflow', description: 'Freigabe-Prozesse konfigurieren' },
]}
collapsible={true}
defaultCollapsed={false}
/>
{/* Token Input */}
{!authToken && (
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">
Admin Token
</label>
<input
type="password"
placeholder="JWT Token eingeben..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
onChange={(e) => {
setAuthToken(e.target.value)
localStorage.setItem('bp_admin_token', e.target.value)
}}
/>
</div>
)}
{/* Tabs */}
<div className="mb-6">
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab.id
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Content */}
<div>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error}
<button
onClick={() => setError(null)}
className="ml-4 text-red-500 hover:text-red-700"
>
X
</button>
</div>
)}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
{/* Documents Tab */}
{activeTab === 'documents' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Dokumente verwalten</h2>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ Neues Dokument
</button>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Dokumente...</div>
) : documents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Dokumente vorhanden
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Typ</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Name</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Beschreibung</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Pflicht</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Erstellt</th>
<th className="text-right py-3 px-4 text-sm font-medium text-slate-500">Aktionen</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4">
<span className="px-2 py-1 bg-slate-100 text-slate-700 rounded text-xs font-medium">
{doc.type}
</span>
</td>
<td className="py-3 px-4 font-medium text-slate-900">{doc.name}</td>
<td className="py-3 px-4 text-slate-600 text-sm">{doc.description}</td>
<td className="py-3 px-4">
{doc.mandatory ? (
<span className="text-green-600">Ja</span>
) : (
<span className="text-slate-400">Nein</span>
)}
</td>
<td className="py-3 px-4 text-sm text-slate-500">
{new Date(doc.created_at).toLocaleDateString('de-DE')}
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => {
setSelectedDocument(doc.id)
setActiveTab('versions')
}}
className="text-purple-600 hover:text-purple-700 text-sm font-medium mr-3"
>
Versionen
</button>
<button className="text-slate-500 hover:text-slate-700 text-sm">
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Versions Tab */}
{activeTab === 'versions' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h2 className="text-lg font-semibold text-slate-900">Versionen</h2>
<select
value={selectedDocument}
onChange={(e) => setSelectedDocument(e.target.value)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Dokument auswaehlen...</option>
{documents.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.name}
</option>
))}
</select>
</div>
{selectedDocument && (
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ Neue Version
</button>
)}
</div>
{!selectedDocument ? (
<div className="text-center py-12 text-slate-500">
Bitte waehlen Sie ein Dokument aus
</div>
) : loading ? (
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
) : versions.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Versionen vorhanden
</div>
) : (
<div className="space-y-4">
{versions.map((version) => (
<div
key={version.id}
className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-slate-900">v{version.version}</span>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
{version.language.toUpperCase()}
</span>
<span
className={`px-2 py-0.5 rounded text-xs ${
version.status === 'published'
? 'bg-green-100 text-green-700'
: version.status === 'draft'
? 'bg-yellow-100 text-yellow-700'
: 'bg-slate-100 text-slate-600'
}`}
>
{version.status}
</span>
</div>
<h3 className="text-slate-700">{version.title}</h3>
<p className="text-sm text-slate-500 mt-1">
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
</p>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
{version.status === 'draft' && (
<button className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
Veroeffentlichen
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Emails Tab - 16 Lifecycle Templates */}
{activeTab === 'emails' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail Vorlagen</h2>
<p className="text-sm text-slate-500 mt-1">16 Lifecycle-Vorlagen fuer automatisierte Kommunikation</p>
</div>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ Neue Vorlage
</button>
</div>
{/* Category Filter */}
<div className="flex flex-wrap gap-2 mb-6">
<span className="text-sm text-slate-500 py-1">Filter:</span>
{emailCategories.map((cat) => (
<span key={cat.key} className={`px-3 py-1 rounded-full text-xs font-medium ${cat.color}`}>
{cat.label}
</span>
))}
</div>
{/* Templates grouped by category */}
{emailCategories.map((category) => (
<div key={category.key} className="mb-8">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${category.color.split(' ')[0]}`}></span>
{category.label}
</h3>
<div className="grid gap-3">
{emailTemplates
.filter((t) => t.category === category.key)
.map((template) => (
<div
key={template.key}
className="border border-slate-200 rounded-lg p-4 flex items-center justify-between hover:border-purple-300 transition-colors bg-white"
>
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-lg ${category.color} flex items-center justify-center text-lg`}>
{category.key === 'onboarding' && ''}
{category.key === 'security' && ''}
{category.key === 'consent' && ''}
{category.key === 'gdpr' && ''}
{category.key === 'lifecycle' && ''}
</div>
<div>
<h4 className="font-medium text-slate-900">{template.name}</h4>
<p className="text-sm text-slate-500">{template.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorschau
</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* GDPR Processes Tab - Articles 15-21 */}
{activeTab === 'gdpr' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">DSGVO Betroffenenrechte</h2>
<p className="text-sm text-slate-500 mt-1">Artikel 15-21 Prozesse und Vorlagen</p>
</div>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ DSR Anfrage erstellen
</button>
</div>
{/* Info Banner */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<span className="text-2xl">*</span>
<div>
<h4 className="font-medium text-purple-900">Data Subject Rights (DSR)</h4>
<p className="text-sm text-purple-700 mt-1">
Hier verwalten Sie alle DSGVO-Anfragen. Jeder Artikel hat definierte Prozesse, SLAs und automatisierte Workflows.
</p>
</div>
</div>
</div>
{/* GDPR Process Cards */}
<div className="space-y-4">
{gdprProcesses.map((process) => (
<div
key={process.article}
className="border border-slate-200 rounded-xl p-5 hover:border-purple-300 transition-colors bg-white"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center font-bold text-lg">
{process.article}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-slate-900">{process.title}</h3>
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
</div>
<p className="text-sm text-slate-600 mb-3">{process.description}</p>
{/* Actions */}
<div className="flex flex-wrap gap-2 mb-3">
{process.actions.map((action, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
{action}
</span>
))}
</div>
{/* SLA */}
<div className="flex items-center gap-4 text-sm">
<span className="text-slate-500">
SLA: <span className="font-medium text-slate-700">{process.sla}</span>
</span>
<span className="text-slate-300">|</span>
<span className="text-slate-500">
Offene Anfragen: <span className="font-medium text-slate-700">0</span>
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<button className="px-3 py-1.5 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg">
Anfragen
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorlage
</button>
</div>
</div>
</div>
))}
</div>
{/* DSR Request Statistics */}
<div className="mt-8 pt-6 border-t border-slate-200">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-4">DSR Uebersicht</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-slate-900">0</div>
<div className="text-xs text-slate-500 mt-1">Offen</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-700">0</div>
<div className="text-xs text-slate-500 mt-1">Erledigt</div>
</div>
<div className="bg-yellow-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-yellow-700">0</div>
<div className="text-xs text-slate-500 mt-1">In Bearbeitung</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-red-700">0</div>
<div className="text-xs text-slate-500 mt-1">Ueberfaellig</div>
</div>
</div>
</div>
</div>
)}
{/* Stats Tab */}
{activeTab === 'stats' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Statistiken</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Aktive Zustimmungen</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Dokumente</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Offene DSR-Anfragen</div>
</div>
</div>
<div className="border border-slate-200 rounded-lg p-6">
<h3 className="font-semibold text-slate-900 mb-4">Zustimmungsrate nach Dokument</h3>
<div className="text-center py-8 text-slate-500">
Noch keine Daten verfuegbar
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,737 +0,0 @@
'use client'
/**
* DSFA - Datenschutz-Folgenabschätzung
*
* Art. 35 DSGVO - Datenschutz-Folgenabschätzung
*
* Migriert auf SDK API: /sdk/v1/dsgvo/dsfa
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface DSFARisk {
id: string
category: string // confidentiality, integrity, availability, rights_freedoms
description: string
likelihood: string // low, medium, high
impact: string // low, medium, high
risk_level: string // low, medium, high, very_high
affected_data?: string[]
}
interface DSFAMitigation {
id: string
risk_id: string
description: string
type: string // technical, organizational, legal
status: string // planned, in_progress, implemented, verified
implemented_at?: string
residual_risk: string // low, medium, high
responsible_party: string
}
interface DSFA {
id: string
tenant_id: string
namespace_id?: string
processing_activity_id?: string
name: string
description: string
processing_description: string
necessity_assessment: string
proportionality_assessment: string
risks: DSFARisk[]
mitigations: DSFAMitigation[]
dpo_consulted: boolean
dpo_opinion?: string
authority_consulted: boolean
authority_reference?: string
status: string // draft, in_progress, completed, approved, rejected
overall_risk_level: string // low, medium, high, very_high
conclusion: string
created_at: string
updated_at: string
created_by: string
approved_by?: string
approved_at?: string
}
export default function DSFAPage() {
const [dsfas, setDsfas] = useState<DSFA[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedProject, setExpandedProject] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'projects' | 'methodology'>('projects')
const [showCreateModal, setShowCreateModal] = useState(false)
const [newDsfa, setNewDsfa] = useState({
name: '',
description: '',
processing_description: '',
necessity_assessment: '',
proportionality_assessment: '',
overall_risk_level: 'medium',
status: 'draft',
conclusion: ''
})
useEffect(() => {
loadDSFAs()
}, [])
async function loadDSFAs() {
setLoading(true)
setError(null)
try {
const res = await fetch('/sdk/v1/dsgvo/dsfa', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
setDsfas(data.dsfas || [])
if ((data.dsfas || []).length > 0) {
setExpandedProject(data.dsfas[0].id)
}
} catch (err) {
console.error('Failed to load DSFAs:', err)
setError('Fehler beim Laden der DSFAs')
} finally {
setLoading(false)
}
}
async function createDSFA() {
try {
const res = await fetch('/sdk/v1/dsgvo/dsfa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(newDsfa)
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
setShowCreateModal(false)
setNewDsfa({
name: '',
description: '',
processing_description: '',
necessity_assessment: '',
proportionality_assessment: '',
overall_risk_level: 'medium',
status: 'draft',
conclusion: ''
})
loadDSFAs()
} catch (err) {
console.error('Failed to create DSFA:', err)
alert('Fehler beim Erstellen der DSFA')
}
}
async function deleteDSFA(id: string) {
if (!confirm('DSFA wirklich löschen?')) return
try {
const res = await fetch(`/sdk/v1/dsgvo/dsfa/${id}`, {
method: 'DELETE',
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
loadDSFAs()
} catch (err) {
console.error('Failed to delete DSFA:', err)
alert('Fehler beim Löschen')
}
}
async function exportDSFA(id: string) {
try {
const res = await fetch(`/sdk/v1/dsgvo/dsfa/${id}/export`, {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dsfa-export.json`
a.click()
window.URL.revokeObjectURL(url)
} catch (err) {
console.error('Export failed:', err)
alert('Export fehlgeschlagen')
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Abgeschlossen</span>
case 'approved':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800">Genehmigt</span>
case 'in_progress':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">In Bearbeitung</span>
case 'draft':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">Entwurf</span>
case 'rejected':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Abgelehnt</span>
default:
return null
}
}
const getRiskBadge = (level: string) => {
switch (level) {
case 'very_high':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Sehr hoch</span>
case 'high':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">Hoch</span>
case 'medium':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Mittel</span>
case 'low':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Niedrig</span>
default:
return null
}
}
const getCategoryLabel = (cat: string) => {
const labels: Record<string, string> = {
'confidentiality': 'Vertraulichkeit',
'integrity': 'Integrität',
'availability': 'Verfügbarkeit',
'rights_freedoms': 'Rechte der Betroffenen',
}
return labels[cat] || cat
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-500">Lade DSFAs...</div>
</div>
)
}
return (
<div>
<PagePurpose
title="Datenschutz-Folgenabschätzung (DSFA)"
purpose="Systematische Risikoanalyse für Verarbeitungen mit hohem Risiko gemäß Art. 35 DSGVO. Dokumentiert Risiken, Maßnahmen und DSB-Freigaben."
audience={['DSB', 'Projektleiter', 'Entwickler', 'Geschäftsführung']}
gdprArticles={['Art. 35 (Datenschutz-Folgenabschätzung)', 'Art. 36 (Vorherige Konsultation)']}
architecture={{
services: ['AI Compliance SDK (Go)', 'PostgreSQL'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'VVT', href: '/dsgvo/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'TOMs', href: '/dsgvo/tom', description: 'Technische Maßnahmen' },
{ name: 'DSR', href: '/dsgvo/dsr', description: 'Betroffenenrechte' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4 text-red-700">
{error}
</div>
)}
{/* Tabs */}
<div className="flex items-center justify-between mb-6">
<div className="flex gap-2">
{[
{ id: 'projects', label: 'DSFA-Projekte' },
{ id: 'methodology', label: 'Methodik' },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-primary-600 text-white'
: 'bg-white text-slate-700 border border-slate-200 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'projects' && (
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
+ Neue DSFA
</button>
)}
</div>
{/* Projects Tab */}
{activeTab === 'projects' && (
<div className="space-y-6">
{/* Statistics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{dsfas.length}</div>
<div className="text-sm text-slate-500">DSFA-Projekte</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">
{dsfas.filter(d => d.status === 'completed' || d.status === 'approved').length}
</div>
<div className="text-sm text-slate-500">Abgeschlossen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-blue-600">
{dsfas.filter(d => d.status === 'in_progress').length}
</div>
<div className="text-sm text-slate-500">In Bearbeitung</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-orange-600">
{dsfas.filter(d => d.overall_risk_level === 'high' || d.overall_risk_level === 'very_high').length}
</div>
<div className="text-sm text-slate-500">Hohes Risiko</div>
</div>
</div>
{/* DSFA List */}
{dsfas.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
<div className="text-slate-400 text-4xl mb-4"></div>
<h3 className="text-lg font-medium text-slate-800 mb-2">Keine DSFAs vorhanden</h3>
<p className="text-slate-500 mb-4">Erstellen Sie eine Datenschutz-Folgenabschätzung für Verarbeitungen mit hohem Risiko.</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
Erste DSFA erstellen
</button>
</div>
) : (
<div className="space-y-4">
{dsfas.map(dsfa => (
<div key={dsfa.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setExpandedProject(expandedProject === dsfa.id ? null : dsfa.id)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="text-left">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-slate-900">{dsfa.name}</h3>
{getStatusBadge(dsfa.status)}
{getRiskBadge(dsfa.overall_risk_level)}
{dsfa.dpo_consulted && (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
DSB-Konsultation
</span>
)}
</div>
<p className="text-sm text-slate-500 mt-1">{dsfa.description}</p>
</div>
</div>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedProject === dsfa.id ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expandedProject === dsfa.id && (
<div className="px-6 pb-6 border-t border-slate-100">
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left: Assessments */}
<div className="space-y-4">
{dsfa.processing_description && (
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Verarbeitungsbeschreibung</h4>
<p className="text-sm text-slate-700 bg-slate-50 rounded-lg p-3">{dsfa.processing_description}</p>
</div>
)}
{dsfa.necessity_assessment && (
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Notwendigkeitsbewertung</h4>
<p className="text-sm text-slate-700 bg-slate-50 rounded-lg p-3">{dsfa.necessity_assessment}</p>
</div>
)}
{dsfa.conclusion && (
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Fazit</h4>
<p className="text-sm text-slate-700 bg-slate-50 rounded-lg p-3">{dsfa.conclusion}</p>
</div>
)}
</div>
{/* Right: Meta */}
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Erstellt</h4>
<p className="text-slate-700">{new Date(dsfa.created_at).toLocaleDateString('de-DE')}</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Aktualisiert</h4>
<p className="text-slate-700">{new Date(dsfa.updated_at).toLocaleDateString('de-DE')}</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">DSB-Konsultation</h4>
<p className={dsfa.dpo_consulted ? 'text-green-600 font-medium' : 'text-yellow-600'}>
{dsfa.dpo_consulted ? 'Ja' : 'Ausstehend'}
</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Aufsichtsbehörde</h4>
<p className={dsfa.authority_consulted ? 'text-green-600 font-medium' : 'text-slate-500'}>
{dsfa.authority_consulted ? 'Konsultiert' : 'Nicht konsultiert'}
</p>
</div>
</div>
{dsfa.dpo_opinion && (
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">DSB-Stellungnahme</h4>
<p className="text-sm text-slate-700 bg-slate-50 rounded-lg p-3">{dsfa.dpo_opinion}</p>
</div>
)}
</div>
</div>
{/* Risks */}
{dsfa.risks && dsfa.risks.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-medium text-slate-500 mb-3">Identifizierte Risiken</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-medium text-slate-500">Kategorie</th>
<th className="text-left py-2 px-3 font-medium text-slate-500">Beschreibung</th>
<th className="text-left py-2 px-3 font-medium text-slate-500">Risiko</th>
</tr>
</thead>
<tbody>
{dsfa.risks.map(risk => (
<tr key={risk.id} className="border-b border-slate-100">
<td className="py-2 px-3 font-medium text-slate-900">{getCategoryLabel(risk.category)}</td>
<td className="py-2 px-3 text-slate-600">{risk.description}</td>
<td className="py-2 px-3">{getRiskBadge(risk.risk_level)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Mitigations */}
{dsfa.mitigations && dsfa.mitigations.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-slate-500 mb-3">Maßnahmen</h4>
<div className="space-y-2">
{dsfa.mitigations.map(mit => (
<div key={mit.id} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div>
<span className="text-sm text-slate-900">{mit.description}</span>
<div className="flex gap-2 mt-1">
<span className={`text-xs px-2 py-0.5 rounded ${
mit.type === 'technical' ? 'bg-blue-100 text-blue-700' :
mit.type === 'organizational' ? 'bg-purple-100 text-purple-700' :
'bg-slate-100 text-slate-600'
}`}>
{mit.type === 'technical' ? 'Technisch' : mit.type === 'organizational' ? 'Organisatorisch' : 'Rechtlich'}
</span>
{getStatusBadge(mit.status)}
</div>
</div>
<div className="text-right">
<div className="text-xs text-slate-500">Restrisiko</div>
{getRiskBadge(mit.residual_risk)}
</div>
</div>
))}
</div>
</div>
)}
<div className="mt-4 pt-4 border-t border-slate-100 flex gap-2">
<button
onClick={() => exportDSFA(dsfa.id)}
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-700 border border-slate-300 rounded-lg"
>
Exportieren
</button>
<button
onClick={() => deleteDSFA(dsfa.id)}
className="px-3 py-1.5 text-sm text-red-600 hover:text-red-700 border border-red-300 rounded-lg"
>
Löschen
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Methodology Tab */}
{activeTab === 'methodology' && (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">DSFA-Prozess nach Art. 35 DSGVO</h2>
<div className="space-y-6">
{[
{
step: 1,
title: 'Schwellwertanalyse',
description: 'Prüfung ob eine DSFA erforderlich ist anhand der Kriterien aus Art. 35 Abs. 3 und der DSK-Positivliste.',
details: ['Verarbeitung besonderer Kategorien (Art. 9)?', 'Systematisches Profiling?', 'Neue Technologien im Einsatz?', 'Daten von Minderjährigen?']
},
{
step: 2,
title: 'Beschreibung der Verarbeitung',
description: 'Systematische Beschreibung der geplanten Verarbeitungsvorgänge und Zwecke.',
details: ['Art, Umfang, Umstände der Verarbeitung', 'Zweck der Verarbeitung', 'Betroffene Personengruppen', 'Verantwortlichkeiten']
},
{
step: 3,
title: 'Notwendigkeit & Verhältnismäßigkeit',
description: 'Bewertung ob die Verarbeitung notwendig und verhältnismäßig ist.',
details: ['Rechtsgrundlage vorhanden?', 'Zweckbindung eingehalten?', 'Datenminimierung beachtet?', 'Speicherbegrenzung definiert?']
},
{
step: 4,
title: 'Risikobewertung',
description: 'Systematische Bewertung der Risiken für Rechte und Freiheiten der Betroffenen.',
details: ['Risiken identifizieren', 'Eintrittswahrscheinlichkeit bewerten', 'Schwere der Auswirkungen bewerten', 'Risiko-Score berechnen']
},
{
step: 5,
title: 'Abhilfemaßnahmen',
description: 'Definition von Maßnahmen zur Eindämmung der identifizierten Risiken.',
details: ['Technische Maßnahmen (TOMs)', 'Organisatorische Maßnahmen', 'Restrisiko-Bewertung', 'Implementierungsplan']
},
{
step: 6,
title: 'DSB-Konsultation',
description: 'Einholung der Stellungnahme des Datenschutzbeauftragten.',
details: ['DSFA dem DSB vorlegen', 'Stellungnahme dokumentieren', 'Ggf. Anpassungen vornehmen', 'Freigabe erteilen']
},
{
step: 7,
title: 'Vorherige Konsultation (Art. 36)',
description: 'Bei verbleibendem hohen Risiko: Konsultation der Aufsichtsbehörde.',
details: ['Nur bei hohem Restrisiko erforderlich', 'Aufsichtsbehörde hat 8 Wochen zur Prüfung', 'Dokumentation der Konsultation', 'Umsetzung der Auflagen']
}
].map(item => (
<div key={item.step} className="flex gap-4">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center font-bold">
{item.step}
</div>
<div className="flex-grow">
<h3 className="font-semibold text-slate-900">{item.title}</h3>
<p className="text-sm text-slate-600 mt-1">{item.description}</p>
<ul className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-slate-500">
{item.details.map((detail, idx) => (
<li key={idx} className="flex items-center gap-1">
<span className="text-primary-400"></span> {detail}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
{/* When is DSFA required */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Wann ist eine DSFA erforderlich?</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<h3 className="font-medium text-slate-700">Art. 35 Abs. 3 - Pflichtfälle:</h3>
<ul className="space-y-2 text-sm text-slate-600">
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
Systematische Bewertung persönlicher Aspekte (Profiling)
</li>
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
Umfangreiche Verarbeitung besonderer Kategorien (Art. 9)
</li>
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
Systematische Überwachung öffentlicher Bereiche
</li>
</ul>
</div>
<div className="space-y-3">
<h3 className="font-medium text-slate-700">Zusätzliche Kriterien (DSK-Liste):</h3>
<ul className="space-y-2 text-sm text-slate-600">
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Verarbeitung von Daten Minderjähriger
</li>
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Einsatz neuer Technologien (z.B. KI)
</li>
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Zusammenführung von Datensätzen
</li>
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Automatisierte Entscheidungsfindung
</li>
</ul>
</div>
</div>
</div>
</div>
)}
{/* Info */}
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h4 className="font-semibold text-yellow-900">Wichtiger Hinweis</h4>
<p className="text-sm text-yellow-800 mt-1">
Eine DSFA ist <strong>vor</strong> Beginn der Verarbeitung durchzuführen. Bei wesentlichen Änderungen
an bestehenden Verarbeitungen muss die DSFA aktualisiert werden. Die Dokumentation muss
der Aufsichtsbehörde auf Anfrage vorgelegt werden können.
</p>
</div>
</div>
</div>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<h3 className="text-lg font-semibold text-slate-900">Neue DSFA erstellen</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newDsfa.name}
onChange={(e) => setNewDsfa({ ...newDsfa, name: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. KI-gestützte Korrektur und Bewertung"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung *</label>
<textarea
value={newDsfa.description}
onChange={(e) => setNewDsfa({ ...newDsfa, description: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-20"
placeholder="Kurze Beschreibung der zu bewertenden Verarbeitung..."
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verarbeitungsbeschreibung</label>
<textarea
value={newDsfa.processing_description}
onChange={(e) => setNewDsfa({ ...newDsfa, processing_description: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-24"
placeholder="Detaillierte Beschreibung der Verarbeitungsvorgänge..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Risikostufe</label>
<select
value={newDsfa.overall_risk_level}
onChange={(e) => setNewDsfa({ ...newDsfa, overall_risk_level: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
<option value="very_high">Sehr hoch</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select
value={newDsfa.status}
onChange={(e) => setNewDsfa({ ...newDsfa, status: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="draft">Entwurf</option>
<option value="in_progress">In Bearbeitung</option>
<option value="completed">Abgeschlossen</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Notwendigkeitsbewertung</label>
<textarea
value={newDsfa.necessity_assessment}
onChange={(e) => setNewDsfa({ ...newDsfa, necessity_assessment: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-20"
placeholder="Warum ist die Verarbeitung notwendig und verhältnismäßig?"
/>
</div>
</div>
<div className="p-6 border-t border-slate-200 flex justify-end gap-3">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Abbrechen
</button>
<button
onClick={createDSFA}
disabled={!newDsfa.name || !newDsfa.description}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
DSFA erstellen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,711 +0,0 @@
'use client'
/**
* DSR (Data Subject Requests) Admin Page
*
* GDPR Article 15-21 Request Management
*
* Migriert auf SDK API: /sdk/v1/dsgvo/dsr
*/
import { useEffect, useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface DSRRequest {
id: string
tenant_id: string
namespace_id?: string
request_type: string // access, rectification, erasure, restriction, portability, objection
status: string // received, verified, in_progress, completed, rejected, extended
subject_name: string
subject_email: string
subject_identifier?: string
request_description: string
request_channel: string // email, form, phone, letter
received_at: string
verified_at?: string
verification_method?: string
deadline_at: string
extended_deadline_at?: string
extension_reason?: string
completed_at?: string
response_sent: boolean
response_sent_at?: string
response_method?: string
rejection_reason?: string
notes?: string
affected_systems?: string[]
assigned_to?: string
created_at: string
updated_at: string
}
interface DSRStats {
total: number
received: number
in_progress: number
completed: number
overdue: number
}
export default function DSRPage() {
const [requests, setRequests] = useState<DSRRequest[]>([])
const [stats, setStats] = useState<DSRStats | null>(null)
const [loading, setLoading] = useState(true)
const [selectedRequest, setSelectedRequest] = useState<DSRRequest | null>(null)
const [filter, setFilter] = useState<string>('all')
const [error, setError] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [newRequest, setNewRequest] = useState({
request_type: 'access',
subject_name: '',
subject_email: '',
subject_identifier: '',
request_description: '',
request_channel: 'email',
notes: ''
})
useEffect(() => {
loadRequests()
}, [])
async function loadRequests() {
setLoading(true)
setError(null)
try {
const res = await fetch('/sdk/v1/dsgvo/dsr', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
const allRequests = data.dsrs || []
setRequests(allRequests)
// Calculate stats
const now = new Date()
setStats({
total: allRequests.length,
received: allRequests.filter((r: DSRRequest) => r.status === 'received' || r.status === 'verified').length,
in_progress: allRequests.filter((r: DSRRequest) => r.status === 'in_progress').length,
completed: allRequests.filter((r: DSRRequest) => r.status === 'completed').length,
overdue: allRequests.filter((r: DSRRequest) => {
const deadline = r.extended_deadline_at ? new Date(r.extended_deadline_at) : new Date(r.deadline_at)
return deadline < now && r.status !== 'completed' && r.status !== 'rejected'
}).length,
})
} catch (err) {
console.error('Failed to load DSRs:', err)
setError('Fehler beim Laden der Anfragen')
} finally {
setLoading(false)
}
}
async function createRequest() {
try {
const res = await fetch('/sdk/v1/dsgvo/dsr', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(newRequest)
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
setShowCreateModal(false)
setNewRequest({
request_type: 'access',
subject_name: '',
subject_email: '',
subject_identifier: '',
request_description: '',
request_channel: 'email',
notes: ''
})
loadRequests()
} catch (err) {
console.error('Failed to create DSR:', err)
alert('Fehler beim Erstellen der Anfrage')
}
}
async function updateStatus(id: string, status: string) {
try {
const res = await fetch(`/sdk/v1/dsgvo/dsr/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify({ status })
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
setSelectedRequest(null)
loadRequests()
} catch (err) {
console.error('Failed to update DSR:', err)
alert('Fehler beim Aktualisieren')
}
}
async function exportDSRs(format: 'csv' | 'json') {
try {
const res = await fetch(`/sdk/v1/dsgvo/export/dsr?format=${format}`, {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dsr-export.${format}`
a.click()
window.URL.revokeObjectURL(url)
} catch (err) {
console.error('Export failed:', err)
alert('Export fehlgeschlagen')
}
}
// Get status badge color
const getStatusColor = (status: string) => {
switch (status) {
case 'received':
return 'bg-slate-100 text-slate-800'
case 'verified':
return 'bg-yellow-100 text-yellow-800'
case 'in_progress':
return 'bg-blue-100 text-blue-800'
case 'completed':
return 'bg-green-100 text-green-800'
case 'rejected':
return 'bg-red-100 text-red-800'
case 'extended':
return 'bg-orange-100 text-orange-800'
default:
return 'bg-slate-100 text-slate-800'
}
}
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
'received': 'Eingegangen',
'verified': 'Verifiziert',
'in_progress': 'In Bearbeitung',
'completed': 'Abgeschlossen',
'rejected': 'Abgelehnt',
'extended': 'Verlängert'
}
return labels[status] || status
}
// Get request type label
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
'access': 'Auskunft (Art. 15)',
'rectification': 'Berichtigung (Art. 16)',
'erasure': 'Löschung (Art. 17)',
'restriction': 'Einschränkung (Art. 18)',
'portability': 'Datenübertragbarkeit (Art. 20)',
'objection': 'Widerspruch (Art. 21)',
}
return labels[type] || type
}
const getChannelLabel = (channel: string) => {
const labels: Record<string, string> = {
'email': 'E-Mail',
'form': 'Formular',
'phone': 'Telefon',
'letter': 'Brief',
}
return labels[channel] || channel
}
// Filter requests
const filteredRequests = requests.filter(r => {
if (filter === 'all') return true
if (filter === 'overdue') {
const deadline = r.extended_deadline_at ? new Date(r.extended_deadline_at) : new Date(r.deadline_at)
return deadline < new Date() && r.status !== 'completed' && r.status !== 'rejected'
}
if (filter === 'open') {
return r.status === 'received' || r.status === 'verified'
}
return r.status === filter
})
// Format date
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
// Check if overdue
const isOverdue = (request: DSRRequest) => {
const deadline = request.extended_deadline_at ? new Date(request.extended_deadline_at) : new Date(request.deadline_at)
return deadline < new Date() && request.status !== 'completed' && request.status !== 'rejected'
}
// Calculate days until deadline
const daysUntilDeadline = (request: DSRRequest) => {
const deadline = request.extended_deadline_at ? new Date(request.extended_deadline_at) : new Date(request.deadline_at)
const now = new Date()
const diff = Math.ceil((deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
return diff
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-500">Lade Anfragen...</div>
</div>
)
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Datenschutzanfragen (DSR)"
purpose="Verwalten Sie alle Betroffenenanfragen nach DSGVO Art. 15-21. Hier bearbeiten Sie Auskunfts-, Lösch- und Berichtigungsanfragen mit automatischer Fristüberwachung."
audience={['DSB', 'Compliance Officer', 'Support']}
gdprArticles={[
'Art. 15 (Auskunftsrecht)',
'Art. 16 (Berichtigung)',
'Art. 17 (Löschung)',
'Art. 18 (Einschränkung)',
'Art. 20 (Datenübertragbarkeit)',
'Art. 21 (Widerspruch)',
]}
architecture={{
services: ['AI Compliance SDK (Go)', 'PostgreSQL'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'VVT', href: '/dsgvo/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'Löschfristen', href: '/dsgvo/loeschfristen', description: 'Aufbewahrungsfristen' },
{ name: 'TOM', href: '/dsgvo/tom', description: 'Technische Maßnahmen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* Header Actions */}
<div className="flex items-center justify-between mb-6">
<div></div>
<div className="flex items-center gap-3">
<button
onClick={() => exportDSRs('csv')}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
CSV Export
</button>
<button
onClick={() => exportDSRs('json')}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
JSON Export
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
+ Neue Anfrage
</button>
</div>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{stats.total}</div>
<div className="text-sm text-slate-500">Gesamt</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-yellow-600">{stats.received}</div>
<div className="text-sm text-slate-500">Offen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-blue-600">{stats.in_progress}</div>
<div className="text-sm text-slate-500">In Bearbeitung</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
<div className="text-sm text-slate-500">Abgeschlossen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className={`text-2xl font-bold ${stats.overdue > 0 ? 'text-red-600' : 'text-slate-400'}`}>
{stats.overdue}
</div>
<div className="text-sm text-slate-500">Überfällig</div>
</div>
</div>
)}
{/* Filter Tabs */}
<div className="flex gap-2 mb-4 overflow-x-auto">
{[
{ value: 'all', label: 'Alle' },
{ value: 'open', label: 'Offen' },
{ value: 'in_progress', label: 'In Bearbeitung' },
{ value: 'completed', label: 'Abgeschlossen' },
{ value: 'overdue', label: 'Überfällig' },
].map((tab) => (
<button
key={tab.value}
onClick={() => setFilter(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
filter === tab.value
? 'bg-primary-600 text-white'
: 'bg-white text-slate-700 border border-slate-200 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Requests Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Betroffener</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kanal</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Frist</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredRequests.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">
Keine Anfragen gefunden
</td>
</tr>
) : (
filteredRequests.map((request) => (
<tr key={request.id} className={isOverdue(request) ? 'bg-red-50' : ''}>
<td className="px-4 py-3 text-sm text-slate-700">{getTypeLabel(request.request_type)}</td>
<td className="px-4 py-3">
<div className="text-sm text-slate-900">{request.subject_name}</div>
<div className="text-xs text-slate-500">{request.subject_email}</div>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(request.status)}`}>
{getStatusLabel(request.status)}
</span>
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{getChannelLabel(request.request_channel)}
</td>
<td className="px-4 py-3">
<div className={`text-sm ${isOverdue(request) ? 'text-red-600 font-medium' : 'text-slate-700'}`}>
{formatDate(request.extended_deadline_at || request.deadline_at)}
</div>
{request.status !== 'completed' && request.status !== 'rejected' && (
<div className={`text-xs ${daysUntilDeadline(request) < 0 ? 'text-red-500' : daysUntilDeadline(request) <= 7 ? 'text-orange-500' : 'text-slate-400'}`}>
{daysUntilDeadline(request) < 0
? `${Math.abs(daysUntilDeadline(request))} Tage überfällig`
: `${daysUntilDeadline(request)} Tage verbleibend`}
</div>
)}
</td>
<td className="px-4 py-3">
<button
onClick={() => setSelectedRequest(request)}
className="text-primary-600 hover:text-primary-700 text-sm font-medium"
>
Details
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Detail Modal */}
{selectedRequest && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">
{getTypeLabel(selectedRequest.request_type)}
</h3>
<button
onClick={() => setSelectedRequest(null)}
className="text-slate-400 hover:text-slate-600"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-slate-500">Betroffener</div>
<div className="font-medium text-slate-900">{selectedRequest.subject_name}</div>
<div className="text-sm text-slate-500">{selectedRequest.subject_email}</div>
</div>
<div>
<div className="text-sm text-slate-500">Status</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(selectedRequest.status)}`}>
{getStatusLabel(selectedRequest.status)}
</span>
</div>
<div>
<div className="text-sm text-slate-500">Eingegangen am</div>
<div className="font-medium text-slate-900">{formatDate(selectedRequest.received_at)}</div>
</div>
<div>
<div className="text-sm text-slate-500">Frist</div>
<div className={`font-medium ${isOverdue(selectedRequest) ? 'text-red-600' : 'text-slate-900'}`}>
{formatDate(selectedRequest.extended_deadline_at || selectedRequest.deadline_at)}
{selectedRequest.extended_deadline_at && (
<span className="text-xs text-orange-600 ml-2">(verlängert)</span>
)}
</div>
</div>
<div>
<div className="text-sm text-slate-500">Kanal</div>
<div className="font-medium text-slate-900">{getChannelLabel(selectedRequest.request_channel)}</div>
</div>
{selectedRequest.subject_identifier && (
<div>
<div className="text-sm text-slate-500">Kunden-ID</div>
<div className="font-medium text-slate-900 font-mono">{selectedRequest.subject_identifier}</div>
</div>
)}
</div>
{selectedRequest.request_description && (
<div>
<div className="text-sm text-slate-500 mb-1">Beschreibung</div>
<div className="bg-slate-50 rounded-lg p-3 text-sm text-slate-700">
{selectedRequest.request_description}
</div>
</div>
)}
{selectedRequest.notes && (
<div>
<div className="text-sm text-slate-500 mb-1">Notizen</div>
<div className="bg-slate-50 rounded-lg p-3 text-sm text-slate-700">
{selectedRequest.notes}
</div>
</div>
)}
{selectedRequest.affected_systems && selectedRequest.affected_systems.length > 0 && (
<div>
<div className="text-sm text-slate-500 mb-1">Betroffene Systeme</div>
<div className="flex flex-wrap gap-2">
{selectedRequest.affected_systems.map((sys, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">
{sys}
</span>
))}
</div>
</div>
)}
<div className="flex gap-2 pt-4 border-t border-slate-200">
{selectedRequest.status === 'received' && (
<button
onClick={() => updateStatus(selectedRequest.id, 'verified')}
className="px-4 py-2 bg-yellow-600 text-white rounded-lg text-sm font-medium hover:bg-yellow-700"
>
Verifizieren
</button>
)}
{(selectedRequest.status === 'received' || selectedRequest.status === 'verified') && (
<button
onClick={() => updateStatus(selectedRequest.id, 'in_progress')}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700"
>
Bearbeitung starten
</button>
)}
{selectedRequest.status === 'in_progress' && (
<>
<button
onClick={() => updateStatus(selectedRequest.id, 'completed')}
className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700"
>
Abschließen
</button>
<button
onClick={() => updateStatus(selectedRequest.id, 'rejected')}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700"
>
Ablehnen
</button>
</>
)}
</div>
</div>
</div>
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<h3 className="text-lg font-semibold text-slate-900">Neue Anfrage erfassen</h3>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ *</label>
<select
value={newRequest.request_type}
onChange={(e) => setNewRequest({ ...newRequest, request_type: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="access">Auskunft (Art. 15)</option>
<option value="rectification">Berichtigung (Art. 16)</option>
<option value="erasure">Löschung (Art. 17)</option>
<option value="restriction">Einschränkung (Art. 18)</option>
<option value="portability">Datenübertragbarkeit (Art. 20)</option>
<option value="objection">Widerspruch (Art. 21)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kanal</label>
<select
value={newRequest.request_channel}
onChange={(e) => setNewRequest({ ...newRequest, request_channel: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="email">E-Mail</option>
<option value="form">Formular</option>
<option value="phone">Telefon</option>
<option value="letter">Brief</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name des Betroffenen *</label>
<input
type="text"
value={newRequest.subject_name}
onChange={(e) => setNewRequest({ ...newRequest, subject_name: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="Max Mustermann"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail *</label>
<input
type="email"
value={newRequest.subject_email}
onChange={(e) => setNewRequest({ ...newRequest, subject_email: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="max@example.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kunden-ID (optional)</label>
<input
type="text"
value={newRequest.subject_identifier}
onChange={(e) => setNewRequest({ ...newRequest, subject_identifier: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. CUST-12345"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung der Anfrage</label>
<textarea
value={newRequest.request_description}
onChange={(e) => setNewRequest({ ...newRequest, request_description: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-24"
placeholder="Was genau wird angefragt..."
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Interne Notizen</label>
<textarea
value={newRequest.notes}
onChange={(e) => setNewRequest({ ...newRequest, notes: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-20"
placeholder="Interne Anmerkungen..."
/>
</div>
</div>
<div className="p-6 border-t border-slate-200 flex justify-end gap-3">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Abbrechen
</button>
<button
onClick={createRequest}
disabled={!newRequest.subject_name || !newRequest.subject_email}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Anfrage erfassen
</button>
</div>
</div>
</div>
)}
{/* Info Box */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<h4 className="font-semibold text-purple-900 mb-2">DSGVO-Fristen</h4>
<ul className="text-sm text-purple-800 space-y-1">
<li>Art. 15 (Auskunft): 1 Monat, verlängerbar auf 3 Monate</li>
<li>Art. 16 (Berichtigung): Unverzüglich</li>
<li>Art. 17 (Löschung): Unverzüglich</li>
<li>Art. 18 (Einschränkung): Unverzüglich</li>
<li>Art. 20 (Datenübertragbarkeit): 1 Monat</li>
<li>Art. 21 (Widerspruch): Unverzüglich</li>
</ul>
</div>
</div>
)
}

View File

@@ -1,498 +0,0 @@
'use client'
/**
* Einwilligungsverwaltung - User Consent Management
*
* Zentrale Uebersicht aller Nutzer-Einwilligungen aus:
* - Website
* - App
* - PWA
*
* Kategorien: Marketing, Statistik, Cookies, Rechtliche Dokumente
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
const API_BASE = '/api/admin/consent'
type Tab = 'overview' | 'documents' | 'cookies' | 'marketing' | 'audit'
interface ConsentStats {
total_users: number
consented_users: number
consent_rate: number
pending_consents: number
}
interface AuditEntry {
id: string
user_id: string
action: string
entity_type: string
entity_id: string
details: Record<string, unknown>
ip_address: string
created_at: string
}
interface ConsentSummary {
category: string
total: number
accepted: number
declined: number
pending: number
rate: number
}
export default function EinwilligungenPage() {
const [activeTab, setActiveTab] = useState<Tab>('overview')
const [stats, setStats] = useState<ConsentStats | null>(null)
const [auditLog, setAuditLog] = useState<AuditEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [authToken, setAuthToken] = useState<string>('')
useEffect(() => {
const token = localStorage.getItem('bp_admin_token')
if (token) {
setAuthToken(token)
}
}, [])
useEffect(() => {
if (activeTab === 'overview') {
loadStats()
} else if (activeTab === 'audit') {
loadAuditLog()
}
}, [activeTab, authToken])
async function loadStats() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/stats`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setStats(data)
} else {
setError('Fehler beim Laden der Statistiken')
}
} catch {
setError('Verbindungsfehler')
} finally {
setLoading(false)
}
}
async function loadAuditLog() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/audit-log?limit=50`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setAuditLog(data.entries || [])
} else {
setError('Fehler beim Laden des Audit-Logs')
}
} catch {
setError('Verbindungsfehler')
} finally {
setLoading(false)
}
}
// Mock data for consent summary (in production, this comes from API)
const consentSummary: ConsentSummary[] = [
{ category: 'AGB', total: 1250, accepted: 1248, declined: 0, pending: 2, rate: 99.8 },
{ category: 'Datenschutz', total: 1250, accepted: 1245, declined: 3, pending: 2, rate: 99.6 },
{ category: 'Cookies (Notwendig)', total: 1250, accepted: 1250, declined: 0, pending: 0, rate: 100 },
{ category: 'Cookies (Analyse)', total: 1250, accepted: 892, declined: 358, pending: 0, rate: 71.4 },
{ category: 'Cookies (Marketing)', total: 1250, accepted: 456, declined: 794, pending: 0, rate: 36.5 },
{ category: 'Newsletter', total: 1250, accepted: 312, declined: 938, pending: 0, rate: 25.0 },
]
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'documents', label: 'Dokumenten-Consents' },
{ id: 'cookies', label: 'Cookie-Consents' },
{ id: 'marketing', label: 'Marketing-Consents' },
{ id: 'audit', label: 'Audit-Trail' },
]
const getActionLabel = (action: string) => {
const labels: Record<string, string> = {
'consent_given': 'Zustimmung erteilt',
'consent_withdrawn': 'Zustimmung widerrufen',
'cookie_consent_updated': 'Cookie-Einstellungen aktualisiert',
'data_access': 'Datenzugriff',
'data_export_requested': 'Datenexport angefordert',
'data_deletion_requested': 'Loeschung angefordert',
'account_suspended': 'Account gesperrt',
'account_restored': 'Account wiederhergestellt',
}
return labels[action] || action
}
const getActionColor = (action: string) => {
if (action.includes('given') || action.includes('restored')) return 'bg-green-100 text-green-700'
if (action.includes('withdrawn') || action.includes('deleted') || action.includes('suspended')) return 'bg-red-100 text-red-700'
return 'bg-blue-100 text-blue-700'
}
return (
<div>
<PagePurpose
title="Einwilligungsverwaltung"
purpose="Zentrale Uebersicht aller Nutzer-Einwilligungen. Hier sehen Sie alle Zustimmungen zu rechtlichen Dokumenten, Cookies, Marketing und Statistik - erfasst ueber Website, App und PWA."
audience={['DSB', 'Compliance Officer', 'Marketing']}
gdprArticles={['Art. 6 (Rechtmaessigkeit)', 'Art. 7 (Einwilligung)', 'Art. 21 (Widerspruch)']}
architecture={{
services: ['consent-service (Go)'],
databases: ['PostgreSQL (user_consents, cookie_consents)'],
}}
relatedPages={[
{ name: 'Consent Dokumente', href: '/compliance/consent', description: 'Rechtliche Dokumente verwalten' },
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management' },
{ name: 'DSR', href: '/compliance/dsr', description: 'Betroffenenanfragen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{stats?.total_users || 1250}</div>
<div className="text-sm text-slate-500">Registrierte Nutzer</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats?.consented_users || 1245}</div>
<div className="text-sm text-slate-500">Mit Zustimmung</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-yellow-600">{stats?.pending_consents || 5}</div>
<div className="text-sm text-slate-500">Ausstehend</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-purple-600">{stats?.consent_rate?.toFixed(1) || 99.6}%</div>
<div className="text-sm text-slate-500">Zustimmungsrate</div>
</div>
</div>
{/* Tabs */}
<div className="mb-6">
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab.id
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error}
<button onClick={() => setError(null)} className="ml-4 text-red-500 hover:text-red-700">X</button>
</div>
)}
{/* Content */}
<div className="bg-white rounded-xl border border-slate-200">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Consent-Uebersicht nach Kategorie</h2>
<div className="space-y-4">
{consentSummary.map((item) => (
<div key={item.category} className="border border-slate-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium text-slate-900">{item.category}</h3>
<span className={`px-2 py-1 rounded text-xs font-medium ${
item.rate >= 90 ? 'bg-green-100 text-green-700' :
item.rate >= 50 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{item.rate}% Zustimmung
</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden mb-3">
<div
className={`h-full ${item.rate >= 90 ? 'bg-green-500' : item.rate >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${item.rate}%` }}
/>
</div>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-slate-500">Gesamt:</span>
<span className="ml-1 font-medium">{item.total}</span>
</div>
<div>
<span className="text-green-600">Akzeptiert:</span>
<span className="ml-1 font-medium">{item.accepted}</span>
</div>
<div>
<span className="text-red-600">Abgelehnt:</span>
<span className="ml-1 font-medium">{item.declined}</span>
</div>
<div>
<span className="text-yellow-600">Ausstehend:</span>
<span className="ml-1 font-medium">{item.pending}</span>
</div>
</div>
</div>
))}
</div>
{/* Export Button */}
<div className="mt-6 pt-6 border-t border-slate-200">
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium">
Consent-Report exportieren (CSV)
</button>
</div>
</div>
)}
{/* Documents Tab */}
{activeTab === 'documents' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Dokumenten-Einwilligungen</h2>
<div className="flex gap-2">
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="">Alle Dokumente</option>
<option value="terms">AGB</option>
<option value="privacy">Datenschutz</option>
<option value="cookies">Cookies</option>
</select>
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="withdrawn">Widerrufen</option>
</select>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Nutzer-ID</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Dokument</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Version</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Status</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Datum</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Quelle</th>
</tr>
</thead>
<tbody>
{/* Sample data - in production, this comes from API */}
{[
{ id: 'usr_123', doc: 'AGB', version: 'v2.1.0', status: 'active', date: '2024-12-15', source: 'Website' },
{ id: 'usr_124', doc: 'Datenschutz', version: 'v3.0.0', status: 'active', date: '2024-12-15', source: 'App' },
{ id: 'usr_125', doc: 'AGB', version: 'v2.1.0', status: 'withdrawn', date: '2024-12-14', source: 'PWA' },
].map((consent, idx) => (
<tr key={idx} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4 font-mono text-sm">{consent.id}</td>
<td className="py-3 px-4">{consent.doc}</td>
<td className="py-3 px-4 text-sm text-slate-500">{consent.version}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${
consent.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{consent.status === 'active' ? 'Aktiv' : 'Widerrufen'}
</span>
</td>
<td className="py-3 px-4 text-sm text-slate-500">{consent.date}</td>
<td className="py-3 px-4">
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">{consent.source}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Cookies Tab */}
{activeTab === 'cookies' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Cookie-Einwilligungen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ name: 'Notwendige Cookies', key: 'necessary', mandatory: true, rate: 100, description: 'Erforderlich fuer Grundfunktionen' },
{ name: 'Funktionale Cookies', key: 'functional', mandatory: false, rate: 82.3, description: 'Verbesserte Nutzererfahrung' },
{ name: 'Analyse Cookies', key: 'analytics', mandatory: false, rate: 71.4, description: 'Anonyme Nutzungsstatistiken' },
{ name: 'Marketing Cookies', key: 'marketing', mandatory: false, rate: 36.5, description: 'Personalisierte Werbung' },
].map((category) => (
<div key={category.key} className="border border-slate-200 rounded-xl p-5">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-slate-900">{category.name}</h3>
<p className="text-sm text-slate-500">{category.description}</p>
</div>
{category.mandatory && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-medium">Pflicht</span>
)}
</div>
<div className="flex items-center gap-4">
<div className="flex-grow h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${category.rate >= 80 ? 'bg-green-500' : category.rate >= 50 ? 'bg-yellow-500' : 'bg-orange-500'}`}
style={{ width: `${category.rate}%` }}
/>
</div>
<span className="text-lg font-bold text-slate-900">{category.rate}%</span>
</div>
</div>
))}
</div>
<div className="mt-6 p-4 bg-slate-50 rounded-lg">
<h4 className="font-medium text-slate-900 mb-2">Cookie-Banner Einstellungen</h4>
<p className="text-sm text-slate-600">
Das Cookie-Banner wird auf allen Plattformen (Website, App, PWA) einheitlich angezeigt.
Nutzer koennen ihre Praeferenzen jederzeit in den Einstellungen aendern.
</p>
</div>
</div>
)}
{/* Marketing Tab */}
{activeTab === 'marketing' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Marketing-Einwilligungen</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
{[
{ name: 'E-Mail Newsletter', rate: 25.0, total: 1250, subscribed: 312 },
{ name: 'Push-Benachrichtigungen', rate: 45.2, total: 1250, subscribed: 565 },
{ name: 'Personalisierte Werbung', rate: 18.5, total: 1250, subscribed: 231 },
].map((channel) => (
<div key={channel.name} className="bg-white border border-slate-200 rounded-xl p-5">
<h3 className="font-semibold text-slate-900 mb-2">{channel.name}</h3>
<div className="text-3xl font-bold text-purple-600 mb-1">{channel.rate}%</div>
<div className="text-sm text-slate-500">{channel.subscribed} von {channel.total} Nutzern</div>
<div className="mt-4 h-2 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-purple-500" style={{ width: `${channel.rate}%` }} />
</div>
</div>
))}
</div>
<div className="border border-slate-200 rounded-lg p-4">
<h4 className="font-medium text-slate-900 mb-3">Opt-Out Anfragen (letzte 30 Tage)</h4>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xl font-bold text-slate-900">23</div>
<div className="text-xs text-slate-500">Newsletter</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xl font-bold text-slate-900">45</div>
<div className="text-xs text-slate-500">Push</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xl font-bold text-slate-900">12</div>
<div className="text-xs text-slate-500">Werbung</div>
</div>
</div>
</div>
</div>
)}
{/* Audit Tab */}
{activeTab === 'audit' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Consent Audit-Trail</h2>
<div className="flex gap-2">
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="">Alle Aktionen</option>
<option value="consent_given">Zustimmung erteilt</option>
<option value="consent_withdrawn">Zustimmung widerrufen</option>
<option value="cookie_consent_updated">Cookie aktualisiert</option>
</select>
<button
onClick={loadAuditLog}
className="px-3 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm hover:bg-slate-200"
>
Aktualisieren
</button>
</div>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Audit-Log...</div>
) : (
<div className="space-y-3">
{(auditLog.length > 0 ? auditLog : [
// Sample data
{ id: '1', user_id: 'usr_123', action: 'consent_given', entity_type: 'document', entity_id: 'doc_agb', details: {}, ip_address: '192.168.1.1', created_at: '2024-12-15T10:30:00Z' },
{ id: '2', user_id: 'usr_124', action: 'cookie_consent_updated', entity_type: 'cookie', entity_id: 'analytics', details: {}, ip_address: '192.168.1.2', created_at: '2024-12-15T10:25:00Z' },
{ id: '3', user_id: 'usr_125', action: 'consent_withdrawn', entity_type: 'document', entity_id: 'doc_newsletter', details: {}, ip_address: '192.168.1.3', created_at: '2024-12-15T10:20:00Z' },
]).map((entry) => (
<div key={entry.id} className="border border-slate-200 rounded-lg p-4 hover:bg-slate-50">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${getActionColor(entry.action)}`}>
{getActionLabel(entry.action)}
</span>
<span className="font-mono text-sm text-slate-600">{entry.user_id}</span>
</div>
<span className="text-sm text-slate-400">
{new Date(entry.created_at).toLocaleString('de-DE')}
</span>
</div>
<div className="mt-2 text-sm text-slate-500">
<span className="text-slate-400">Entity:</span> {entry.entity_type} / {entry.entity_id}
<span className="mx-2 text-slate-300">|</span>
<span className="text-slate-400">IP:</span> {entry.ip_address}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* GDPR Notice */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-purple-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-semibold text-purple-900">DSGVO-Hinweis</h4>
<p className="text-sm text-purple-800 mt-1">
Alle Einwilligungen werden revisionssicher gespeichert und koennen jederzeit nachgewiesen werden.
Nutzer koennen ihre Einwilligungen gemaess Art. 7 Abs. 3 DSGVO jederzeit widerrufen.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,864 +0,0 @@
'use client'
/**
* Escalation Queue Page
*
* DSB Review & Approval Workflow for UCCA Assessments
* Implements E0-E3 escalation levels with SLA tracking
*
* API: /sdk/v1/ucca/escalations
*/
import { useEffect, useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// Types
interface Escalation {
id: string
tenant_id: string
assessment_id: string
escalation_level: 'E0' | 'E1' | 'E2' | 'E3'
escalation_reason: string
assigned_to?: string
assigned_role?: string
assigned_at?: string
status: 'pending' | 'assigned' | 'in_review' | 'approved' | 'rejected' | 'returned'
reviewer_id?: string
reviewer_notes?: string
reviewed_at?: string
decision?: 'approve' | 'reject' | 'modify' | 'escalate'
decision_notes?: string
decision_at?: string
conditions?: string[]
created_at: string
updated_at: string
due_date?: string
notification_sent: boolean
// Joined fields
assessment_title?: string
assessment_feasibility?: string
assessment_risk_score?: number
assessment_domain?: string
}
interface EscalationHistory {
id: string
escalation_id: string
action: string
old_status?: string
new_status?: string
old_level?: string
new_level?: string
actor_id: string
actor_role?: string
notes?: string
created_at: string
}
interface EscalationStats {
total_pending: number
total_in_review: number
total_approved: number
total_rejected: number
by_level: Record<string, number>
overdue_sla: number
approaching_sla: number
avg_resolution_hours: number
}
interface DSBPoolMember {
id: string
tenant_id: string
user_id: string
user_name: string
user_email: string
role: string
is_active: boolean
max_concurrent_reviews: number
current_reviews: number
created_at: string
updated_at: string
}
// Constants
const LEVEL_CONFIG = {
E0: { label: 'Auto-Approve', color: 'bg-green-100 text-green-800', description: 'Automatische Freigabe' },
E1: { label: 'Team-Lead', color: 'bg-blue-100 text-blue-800', description: 'Team-Lead Review erforderlich' },
E2: { label: 'DSB', color: 'bg-yellow-100 text-yellow-800', description: 'DSB-Konsultation erforderlich' },
E3: { label: 'DSB + Legal', color: 'bg-red-100 text-red-800', description: 'DSB + Rechtsabteilung erforderlich' },
}
const STATUS_CONFIG = {
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-800' },
assigned: { label: 'Zugewiesen', color: 'bg-blue-100 text-blue-800' },
in_review: { label: 'In Prüfung', color: 'bg-yellow-100 text-yellow-800' },
approved: { label: 'Genehmigt', color: 'bg-green-100 text-green-800' },
rejected: { label: 'Abgelehnt', color: 'bg-red-100 text-red-800' },
returned: { label: 'Zurückgegeben', color: 'bg-orange-100 text-orange-800' },
}
export default function EscalationsPage() {
const [escalations, setEscalations] = useState<Escalation[]>([])
const [stats, setStats] = useState<EscalationStats | null>(null)
const [dsbPool, setDsbPool] = useState<DSBPoolMember[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filters
const [statusFilter, setStatusFilter] = useState<string>('pending')
const [levelFilter, setLevelFilter] = useState<string>('')
const [myReviewsOnly, setMyReviewsOnly] = useState(false)
// Selected escalation for detail view
const [selectedEscalation, setSelectedEscalation] = useState<Escalation | null>(null)
const [escalationHistory, setEscalationHistory] = useState<EscalationHistory[]>([])
// Decision modal
const [showDecisionModal, setShowDecisionModal] = useState(false)
const [decisionForm, setDecisionForm] = useState({
decision: 'approve' as 'approve' | 'reject' | 'modify' | 'escalate',
decision_notes: '',
conditions: [] as string[],
})
const [newCondition, setNewCondition] = useState('')
// DSB Pool modal
const [showDSBPoolModal, setShowDSBPoolModal] = useState(false)
const [newMember, setNewMember] = useState({
user_id: '',
user_name: '',
user_email: '',
role: 'dsb',
max_concurrent_reviews: 10,
})
// Load data
useEffect(() => {
loadEscalations()
loadStats()
loadDSBPool()
}, [statusFilter, levelFilter, myReviewsOnly])
async function loadEscalations() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
if (statusFilter && statusFilter !== 'all') params.append('status', statusFilter)
if (levelFilter) params.append('level', levelFilter)
if (myReviewsOnly) params.append('my_reviews', 'true')
const res = await fetch(`/sdk/v1/ucca/escalations?${params}`, {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setEscalations(data.escalations || [])
} catch (err) {
console.error('Failed to load escalations:', err)
setError('Fehler beim Laden der Eskalationen')
} finally {
setLoading(false)
}
}
async function loadStats() {
try {
const res = await fetch('/sdk/v1/ucca/escalations/stats', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setStats(data)
} catch (err) {
console.error('Failed to load stats:', err)
}
}
async function loadDSBPool() {
try {
const res = await fetch('/sdk/v1/ucca/dsb-pool', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setDsbPool(data.members || [])
} catch (err) {
console.error('Failed to load DSB pool:', err)
}
}
async function loadEscalationDetail(id: string) {
try {
const res = await fetch(`/sdk/v1/ucca/escalations/${id}`, {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setSelectedEscalation(data.escalation)
setEscalationHistory(data.history || [])
} catch (err) {
console.error('Failed to load escalation detail:', err)
}
}
async function startReview(id: string) {
try {
const res = await fetch(`/sdk/v1/ucca/escalations/${id}/review`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
loadEscalations()
if (selectedEscalation?.id === id) {
loadEscalationDetail(id)
}
} catch (err) {
console.error('Failed to start review:', err)
alert('Fehler beim Starten der Prüfung')
}
}
async function submitDecision() {
if (!selectedEscalation) return
try {
const res = await fetch(`/sdk/v1/ucca/escalations/${selectedEscalation.id}/decide`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(decisionForm)
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setShowDecisionModal(false)
setDecisionForm({ decision: 'approve', decision_notes: '', conditions: [] })
loadEscalations()
loadStats()
setSelectedEscalation(null)
} catch (err) {
console.error('Failed to submit decision:', err)
alert('Fehler beim Speichern der Entscheidung')
}
}
async function assignEscalation(escalationId: string, userId: string) {
try {
const res = await fetch(`/sdk/v1/ucca/escalations/${escalationId}/assign`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify({ assigned_to: userId })
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
loadEscalations()
if (selectedEscalation?.id === escalationId) {
loadEscalationDetail(escalationId)
}
} catch (err) {
console.error('Failed to assign escalation:', err)
alert('Fehler bei der Zuweisung')
}
}
async function addDSBPoolMember() {
try {
const res = await fetch('/sdk/v1/ucca/dsb-pool', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(newMember)
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setShowDSBPoolModal(false)
setNewMember({ user_id: '', user_name: '', user_email: '', role: 'dsb', max_concurrent_reviews: 10 })
loadDSBPool()
} catch (err) {
console.error('Failed to add DSB pool member:', err)
alert('Fehler beim Hinzufügen')
}
}
function addCondition() {
if (newCondition.trim()) {
setDecisionForm(prev => ({
...prev,
conditions: [...prev.conditions, newCondition.trim()]
}))
setNewCondition('')
}
}
function removeCondition(index: number) {
setDecisionForm(prev => ({
...prev,
conditions: prev.conditions.filter((_, i) => i !== index)
}))
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function isOverdue(dueDate?: string) {
if (!dueDate) return false
return new Date(dueDate) < new Date()
}
function getTimeRemaining(dueDate?: string) {
if (!dueDate) return null
const now = new Date()
const due = new Date(dueDate)
const diff = due.getTime() - now.getTime()
if (diff < 0) return 'Überfällig'
const hours = Math.floor(diff / (1000 * 60 * 60))
if (hours < 24) return `${hours}h verbleibend`
const days = Math.floor(hours / 24)
return `${days}d verbleibend`
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Eskalations-Queue</h1>
<p className="text-gray-600 mt-1">DSB Review & Freigabe-Workflow für UCCA Assessments</p>
</div>
<button
onClick={() => setShowDSBPoolModal(true)}
className="px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors"
>
DSB-Pool verwalten
</button>
</div>
<PagePurpose
title="Eskalations-Queue"
purpose="Verwaltung von Eskalationen aus dem Advisory Board. DSB und Team-Leads prüfen risikoreiche Use-Cases (E1-E3) und erteilen Freigaben oder Ablehnungen mit Auflagen."
audience={['DSB', 'Team-Leads', 'Legal']}
gdprArticles={['Art. 5', 'Art. 22', 'Art. 35', 'Art. 36']}
/>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div className="bg-white rounded-lg border p-4">
<div className="text-2xl font-bold text-gray-900">{stats.total_pending}</div>
<div className="text-sm text-gray-600">Ausstehend</div>
</div>
<div className="bg-white rounded-lg border p-4">
<div className="text-2xl font-bold text-yellow-600">{stats.total_in_review}</div>
<div className="text-sm text-gray-600">In Prüfung</div>
</div>
<div className="bg-white rounded-lg border p-4">
<div className="text-2xl font-bold text-green-600">{stats.total_approved}</div>
<div className="text-sm text-gray-600">Genehmigt</div>
</div>
<div className="bg-white rounded-lg border p-4">
<div className="text-2xl font-bold text-red-600">{stats.total_rejected}</div>
<div className="text-sm text-gray-600">Abgelehnt</div>
</div>
<div className="bg-white rounded-lg border p-4">
<div className="text-2xl font-bold text-red-600">{stats.overdue_sla}</div>
<div className="text-sm text-gray-600">SLA überschritten</div>
</div>
<div className="bg-white rounded-lg border p-4">
<div className="text-2xl font-bold text-orange-600">{stats.approaching_sla}</div>
<div className="text-sm text-gray-600">SLA gefährdet</div>
</div>
</div>
)}
{/* Level Distribution */}
{stats && stats.by_level && (
<div className="bg-white rounded-lg border p-4">
<h3 className="font-medium text-gray-900 mb-3">Verteilung nach Eskalationsstufe</h3>
<div className="flex gap-4">
{Object.entries(LEVEL_CONFIG).map(([level, config]) => (
<div key={level} className="flex items-center gap-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${config.color}`}>
{level}
</span>
<span className="text-gray-600">{stats.by_level[level] || 0}</span>
</div>
))}
</div>
</div>
)}
{/* Filters */}
<div className="bg-white rounded-lg border p-4">
<div className="flex flex-wrap gap-4 items-center">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="pending">Ausstehend</option>
<option value="assigned">Zugewiesen</option>
<option value="in_review">In Prüfung</option>
<option value="approved">Genehmigt</option>
<option value="rejected">Abgelehnt</option>
<option value="returned">Zurückgegeben</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Level</label>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="">Alle</option>
<option value="E1">E1 - Team-Lead</option>
<option value="E2">E2 - DSB</option>
<option value="E3">E3 - DSB + Legal</option>
</select>
</div>
<div className="flex items-center gap-2 pt-6">
<input
type="checkbox"
id="myReviews"
checked={myReviewsOnly}
onChange={(e) => setMyReviewsOnly(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="myReviews" className="text-sm text-gray-700">Nur meine Zuweisungen</label>
</div>
<div className="ml-auto pt-6">
<button
onClick={loadEscalations}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
Aktualisieren
</button>
</div>
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Escalation List */}
<div className="lg:col-span-2 space-y-4">
{loading ? (
<div className="bg-white rounded-lg border p-8 text-center text-gray-500">
Laden...
</div>
) : escalations.length === 0 ? (
<div className="bg-white rounded-lg border p-8 text-center text-gray-500">
Keine Eskalationen gefunden
</div>
) : (
escalations.map((esc) => (
<div
key={esc.id}
onClick={() => loadEscalationDetail(esc.id)}
className={`bg-white rounded-lg border p-4 cursor-pointer hover:border-violet-300 transition-colors ${
selectedEscalation?.id === esc.id ? 'border-violet-500 ring-2 ring-violet-200' : ''
} ${isOverdue(esc.due_date) && esc.status !== 'approved' && esc.status !== 'rejected' ? 'border-red-300 bg-red-50' : ''}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${LEVEL_CONFIG[esc.escalation_level].color}`}>
{esc.escalation_level}
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${STATUS_CONFIG[esc.status].color}`}>
{STATUS_CONFIG[esc.status].label}
</span>
{esc.due_date && (
<span className={`text-xs ${isOverdue(esc.due_date) ? 'text-red-600 font-medium' : 'text-gray-500'}`}>
{getTimeRemaining(esc.due_date)}
</span>
)}
</div>
<h3 className="font-medium text-gray-900">
{esc.assessment_title || `Assessment ${esc.assessment_id.slice(0, 8)}`}
</h3>
<p className="text-sm text-gray-600 mt-1 line-clamp-2">{esc.escalation_reason}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span>Erstellt: {formatDate(esc.created_at)}</span>
{esc.assessment_risk_score !== undefined && (
<span>Risk: {esc.assessment_risk_score}/100</span>
)}
{esc.assessment_domain && (
<span>Domain: {esc.assessment_domain}</span>
)}
</div>
</div>
{esc.status === 'pending' && (
<button
onClick={(e) => {
e.stopPropagation()
startReview(esc.id)
}}
className="px-3 py-1 text-sm bg-violet-100 text-violet-700 rounded hover:bg-violet-200 transition-colors"
>
Review starten
</button>
)}
</div>
</div>
))
)}
</div>
{/* Detail Panel */}
<div className="lg:col-span-1">
{selectedEscalation ? (
<div className="bg-white rounded-lg border p-4 sticky top-6 space-y-4">
<h3 className="font-semibold text-gray-900">Detail</h3>
{/* Status & Level */}
<div className="flex gap-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${LEVEL_CONFIG[selectedEscalation.escalation_level].color}`}>
{selectedEscalation.escalation_level} - {LEVEL_CONFIG[selectedEscalation.escalation_level].label}
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${STATUS_CONFIG[selectedEscalation.status].color}`}>
{STATUS_CONFIG[selectedEscalation.status].label}
</span>
</div>
{/* Reason */}
<div>
<div className="text-sm font-medium text-gray-700">Grund</div>
<div className="text-sm text-gray-600 mt-1">{selectedEscalation.escalation_reason}</div>
</div>
{/* SLA */}
{selectedEscalation.due_date && (
<div>
<div className="text-sm font-medium text-gray-700">SLA Deadline</div>
<div className={`text-sm mt-1 ${isOverdue(selectedEscalation.due_date) ? 'text-red-600 font-medium' : 'text-gray-600'}`}>
{formatDate(selectedEscalation.due_date)}
{isOverdue(selectedEscalation.due_date) && ' (Überfällig!)'}
</div>
</div>
)}
{/* Assignment */}
<div>
<div className="text-sm font-medium text-gray-700">Zugewiesen an</div>
{selectedEscalation.assigned_to ? (
<div className="text-sm text-gray-600 mt-1">
{selectedEscalation.assigned_role || 'Unbekannt'}
</div>
) : (
<div className="mt-2">
<select
onChange={(e) => {
if (e.target.value) {
assignEscalation(selectedEscalation.id, e.target.value)
}
}}
className="w-full border rounded px-2 py-1 text-sm"
defaultValue=""
>
<option value="">Reviewer auswählen...</option>
{dsbPool.filter(m => m.is_active).map(member => (
<option key={member.user_id} value={member.user_id}>
{member.user_name} ({member.role}) - {member.current_reviews}/{member.max_concurrent_reviews}
</option>
))}
</select>
</div>
)}
</div>
{/* Decision */}
{selectedEscalation.decision && (
<div>
<div className="text-sm font-medium text-gray-700">Entscheidung</div>
<div className="text-sm text-gray-600 mt-1">
{selectedEscalation.decision === 'approve' && '✅ Genehmigt'}
{selectedEscalation.decision === 'reject' && '❌ Abgelehnt'}
{selectedEscalation.decision === 'modify' && '🔄 Änderungen erforderlich'}
{selectedEscalation.decision === 'escalate' && '⬆️ Eskaliert'}
</div>
{selectedEscalation.decision_notes && (
<div className="text-sm text-gray-500 mt-1">{selectedEscalation.decision_notes}</div>
)}
{selectedEscalation.conditions && selectedEscalation.conditions.length > 0 && (
<div className="mt-2">
<div className="text-xs font-medium text-gray-700">Auflagen:</div>
<ul className="list-disc list-inside text-xs text-gray-600">
{selectedEscalation.conditions.map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Actions */}
{(selectedEscalation.status === 'assigned' || selectedEscalation.status === 'in_review') && (
<div className="pt-4 border-t">
<button
onClick={() => setShowDecisionModal(true)}
className="w-full px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors"
>
Entscheidung treffen
</button>
</div>
)}
{/* History */}
{escalationHistory.length > 0 && (
<div className="pt-4 border-t">
<div className="text-sm font-medium text-gray-700 mb-2">Verlauf</div>
<div className="space-y-2 max-h-48 overflow-y-auto">
{escalationHistory.map((h) => (
<div key={h.id} className="text-xs border-l-2 border-gray-200 pl-2">
<div className="text-gray-900">{h.action}</div>
{h.notes && <div className="text-gray-500">{h.notes}</div>}
<div className="text-gray-400">{formatDate(h.created_at)}</div>
</div>
))}
</div>
</div>
)}
{/* Link to Assessment */}
<div className="pt-4 border-t">
<a
href={`/dsgvo/advisory-board?assessment=${selectedEscalation.assessment_id}`}
className="text-sm text-violet-600 hover:text-violet-800"
>
Assessment anzeigen
</a>
</div>
</div>
) : (
<div className="bg-gray-50 rounded-lg border border-dashed p-8 text-center text-gray-500">
Wählen Sie eine Eskalation aus, um Details zu sehen
</div>
)}
</div>
</div>
{/* Decision Modal */}
{showDecisionModal && selectedEscalation && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold mb-4">Entscheidung für {selectedEscalation.escalation_level}</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Entscheidung</label>
<select
value={decisionForm.decision}
onChange={(e) => setDecisionForm(prev => ({ ...prev, decision: e.target.value as any }))}
className="w-full border rounded-lg px-3 py-2"
>
<option value="approve"> Genehmigen</option>
<option value="reject"> Ablehnen</option>
<option value="modify">🔄 Änderungen erforderlich</option>
<option value="escalate"> Weiter eskalieren</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Begründung</label>
<textarea
value={decisionForm.decision_notes}
onChange={(e) => setDecisionForm(prev => ({ ...prev, decision_notes: e.target.value }))}
rows={3}
className="w-full border rounded-lg px-3 py-2"
placeholder="Begründung für die Entscheidung..."
/>
</div>
{(decisionForm.decision === 'approve' || decisionForm.decision === 'modify') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Auflagen (optional)</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newCondition}
onChange={(e) => setNewCondition(e.target.value)}
placeholder="Neue Auflage eingeben..."
className="flex-1 border rounded-lg px-3 py-2 text-sm"
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCondition())}
/>
<button
onClick={addCondition}
className="px-3 py-2 bg-gray-100 rounded-lg hover:bg-gray-200"
>
+
</button>
</div>
{decisionForm.conditions.length > 0 && (
<ul className="space-y-1">
{decisionForm.conditions.map((c, i) => (
<li key={i} className="flex items-center justify-between bg-gray-50 px-2 py-1 rounded text-sm">
<span>{c}</span>
<button
onClick={() => removeCondition(i)}
className="text-red-500 hover:text-red-700"
>
×
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setShowDecisionModal(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Abbrechen
</button>
<button
onClick={submitDecision}
className="px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700"
>
Entscheidung speichern
</button>
</div>
</div>
</div>
)}
{/* DSB Pool Modal */}
{showDSBPoolModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4">
<h3 className="text-lg font-semibold mb-4">DSB-Pool verwalten</h3>
{/* Current Members */}
<div className="mb-6">
<h4 className="text-sm font-medium text-gray-700 mb-2">Aktuelle Mitglieder</h4>
{dsbPool.length === 0 ? (
<p className="text-sm text-gray-500">Keine Mitglieder im Pool</p>
) : (
<div className="border rounded-lg divide-y">
{dsbPool.map(member => (
<div key={member.id} className="p-3 flex items-center justify-between">
<div>
<div className="font-medium">{member.user_name}</div>
<div className="text-sm text-gray-500">{member.user_email}</div>
</div>
<div className="flex items-center gap-4">
<span className={`px-2 py-1 rounded text-xs ${
member.role === 'dsb' ? 'bg-violet-100 text-violet-800' :
member.role === 'team_lead' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{member.role}
</span>
<span className="text-sm text-gray-600">
{member.current_reviews}/{member.max_concurrent_reviews} Reviews
</span>
<span className={`w-2 h-2 rounded-full ${member.is_active ? 'bg-green-500' : 'bg-gray-300'}`} />
</div>
</div>
))}
</div>
)}
</div>
{/* Add New Member */}
<div className="border-t pt-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Neues Mitglied hinzufügen</h4>
<div className="grid grid-cols-2 gap-4">
<input
type="text"
value={newMember.user_name}
onChange={(e) => setNewMember(prev => ({ ...prev, user_name: e.target.value }))}
placeholder="Name"
className="border rounded-lg px-3 py-2"
/>
<input
type="email"
value={newMember.user_email}
onChange={(e) => setNewMember(prev => ({ ...prev, user_email: e.target.value }))}
placeholder="E-Mail"
className="border rounded-lg px-3 py-2"
/>
<select
value={newMember.role}
onChange={(e) => setNewMember(prev => ({ ...prev, role: e.target.value }))}
className="border rounded-lg px-3 py-2"
>
<option value="dsb">DSB</option>
<option value="deputy_dsb">Stellv. DSB</option>
<option value="team_lead">Team-Lead</option>
<option value="legal">Legal</option>
</select>
<input
type="number"
value={newMember.max_concurrent_reviews}
onChange={(e) => setNewMember(prev => ({ ...prev, max_concurrent_reviews: parseInt(e.target.value) || 10 }))}
placeholder="Max Reviews"
className="border rounded-lg px-3 py-2"
/>
</div>
<button
onClick={addDSBPoolMember}
disabled={!newMember.user_name || !newMember.user_email}
className="mt-4 px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Hinzufügen
</button>
</div>
<div className="flex justify-end mt-6">
<button
onClick={() => setShowDSBPoolModal(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Schließen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,542 +0,0 @@
'use client'
/**
* Löschfristen - Data Retention Management
*
* Art. 17 DSGVO - Recht auf Löschung
* Art. 5 Abs. 1 lit. e DSGVO - Speicherbegrenzung
*
* Migriert auf SDK API: /sdk/v1/dsgvo/retention-policies
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface RetentionPolicy {
id: string
tenant_id: string
namespace_id?: string
name: string
description: string
data_category: string
retention_period_days: number
retention_period_text: string
legal_basis: string
legal_reference?: string
deletion_method: string // automatic, manual, anonymization
deletion_procedure?: string
exception_criteria?: string
applicable_systems?: string[]
responsible_person: string
responsible_department: string
status: string // draft, active, archived
last_review_at?: string
next_review_at?: string
created_at: string
updated_at: string
}
export default function LoeschfristenPage() {
const [policies, setPolicies] = useState<RetentionPolicy[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [newPolicy, setNewPolicy] = useState({
name: '',
description: '',
data_category: '',
retention_period_days: 365,
retention_period_text: '1 Jahr',
legal_basis: 'legal_requirement',
legal_reference: '',
deletion_method: 'automatic',
deletion_procedure: '',
responsible_person: '',
responsible_department: '',
status: 'draft'
})
useEffect(() => {
loadPolicies()
}, [])
async function loadPolicies() {
setLoading(true)
setError(null)
try {
const res = await fetch('/sdk/v1/dsgvo/retention-policies', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
setPolicies(data.policies || [])
} catch (err) {
console.error('Failed to load retention policies:', err)
setError('Fehler beim Laden der Löschfristen')
} finally {
setLoading(false)
}
}
async function createPolicy() {
try {
const res = await fetch('/sdk/v1/dsgvo/retention-policies', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(newPolicy)
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
setShowCreateModal(false)
setNewPolicy({
name: '',
description: '',
data_category: '',
retention_period_days: 365,
retention_period_text: '1 Jahr',
legal_basis: 'legal_requirement',
legal_reference: '',
deletion_method: 'automatic',
deletion_procedure: '',
responsible_person: '',
responsible_department: '',
status: 'draft'
})
loadPolicies()
} catch (err) {
console.error('Failed to create policy:', err)
alert('Fehler beim Erstellen der Löschfrist')
}
}
async function exportPolicies(format: 'csv' | 'json') {
try {
const res = await fetch(`/sdk/v1/dsgvo/export/retention?format=${format}`, {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `loeschfristen-export.${format}`
a.click()
window.URL.revokeObjectURL(url)
} catch (err) {
console.error('Export failed:', err)
alert('Export fehlgeschlagen')
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Aktiv</span>
case 'draft':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Entwurf</span>
case 'archived':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">Archiviert</span>
default:
return null
}
}
const getDeletionMethodBadge = (method: string) => {
switch (method) {
case 'automatic':
return <span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Auto-Löschung</span>
case 'manual':
return <span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">Manuell</span>
case 'anonymization':
return <span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">Anonymisierung</span>
default:
return null
}
}
const getLegalBasisLabel = (basis: string) => {
const labels: Record<string, string> = {
'legal_requirement': 'Gesetzliche Pflicht',
'consent': 'Einwilligung',
'legitimate_interest': 'Berechtigtes Interesse',
'contract': 'Vertragserfüllung',
}
return labels[basis] || basis
}
// Group policies by status
const activePolicies = policies.filter(p => p.status === 'active')
const draftPolicies = policies.filter(p => p.status === 'draft')
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-500">Lade Löschfristen...</div>
</div>
)
}
return (
<div>
<PagePurpose
title="Löschfristen & Datenaufbewahrung"
purpose="Verwaltung von Aufbewahrungsfristen und automatischen Löschungen gemäß DSGVO Art. 5 (Speicherbegrenzung) und Art. 17 (Recht auf Löschung)."
audience={['DSB', 'IT-Admin', 'Compliance Officer']}
gdprArticles={['Art. 5 Abs. 1 lit. e (Speicherbegrenzung)', 'Art. 17 (Recht auf Löschung)']}
architecture={{
services: ['AI Compliance SDK (Go)', 'PostgreSQL'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'VVT', href: '/dsgvo/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'DSR', href: '/dsgvo/dsr', description: 'Löschanfragen' },
{ name: 'TOM', href: '/dsgvo/tom', description: 'Technische Maßnahmen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4 text-red-700">
{error}
</div>
)}
{/* Header Actions */}
<div className="flex items-center justify-between mb-6">
<div></div>
<div className="flex items-center gap-3">
<button
onClick={() => exportPolicies('csv')}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
CSV Export
</button>
<button
onClick={() => exportPolicies('json')}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
JSON Export
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
+ Neue Löschfrist
</button>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{policies.length}</div>
<div className="text-sm text-slate-500">Löschfristen gesamt</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{activePolicies.length}</div>
<div className="text-sm text-slate-500">Aktive Richtlinien</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-yellow-600">{draftPolicies.length}</div>
<div className="text-sm text-slate-500">Entwürfe</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-purple-600">
{policies.filter(p => p.deletion_method === 'automatic').length}
</div>
<div className="text-sm text-slate-500">Auto-Löschung</div>
</div>
</div>
{/* Policies List */}
{policies.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
<div className="text-slate-400 text-4xl mb-4">🗑</div>
<h3 className="text-lg font-medium text-slate-800 mb-2">Keine Löschfristen definiert</h3>
<p className="text-slate-500 mb-4">Legen Sie Aufbewahrungsfristen für verschiedene Datenkategorien an.</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
Erste Löschfrist anlegen
</button>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200">
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Aufbewahrungsfristen</h2>
<div className="space-y-4">
{policies.map((policy) => (
<div key={policy.id} className="border border-slate-200 rounded-lg p-4 hover:border-primary-300 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-grow">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold text-slate-900">{policy.name}</h3>
{getStatusBadge(policy.status)}
{getDeletionMethodBadge(policy.deletion_method)}
</div>
{policy.description && (
<p className="text-sm text-slate-600 mb-3">{policy.description}</p>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-slate-500">Datenkategorie:</span>
<span className="ml-1 font-medium text-slate-700">{policy.data_category}</span>
</div>
<div>
<span className="text-slate-500">Frist:</span>
<span className="ml-1 font-medium text-slate-700">{policy.retention_period_text}</span>
<span className="text-slate-400 ml-1">({policy.retention_period_days} Tage)</span>
</div>
<div>
<span className="text-slate-500">Rechtsgrundlage:</span>
<span className="ml-1 text-slate-600">{getLegalBasisLabel(policy.legal_basis)}</span>
</div>
{policy.legal_reference && (
<div>
<span className="text-slate-500">Referenz:</span>
<span className="ml-1 text-slate-600 font-mono text-xs">{policy.legal_reference}</span>
</div>
)}
</div>
<div className="mt-3 flex flex-wrap gap-4 text-xs text-slate-500">
{policy.responsible_person && (
<span>Verantwortlich: {policy.responsible_person}</span>
)}
{policy.responsible_department && (
<span>Abteilung: {policy.responsible_department}</span>
)}
{policy.last_review_at && (
<span>Letzte Prüfung: {new Date(policy.last_review_at).toLocaleDateString('de-DE')}</span>
)}
{policy.next_review_at && (
<span>Nächste Prüfung: {new Date(policy.next_review_at).toLocaleDateString('de-DE')}</span>
)}
</div>
{policy.applicable_systems && policy.applicable_systems.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{policy.applicable_systems.map((sys, idx) => (
<span key={idx} className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
{sys}
</span>
))}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Info */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-purple-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-semibold text-purple-900">Speicherbegrenzung (Art. 5)</h4>
<p className="text-sm text-purple-800 mt-1">
Personenbezogene Daten dürfen nur so lange gespeichert werden, wie es für die Zwecke
erforderlich ist. Die automatische Löschung stellt die Einhaltung dieser Vorgabe sicher.
</p>
</div>
</div>
</div>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<h3 className="text-lg font-semibold text-slate-900">Neue Löschfrist anlegen</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newPolicy.name}
onChange={(e) => setNewPolicy({ ...newPolicy, name: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. Aufbewahrung Nutzerkonten"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={newPolicy.description}
onChange={(e) => setNewPolicy({ ...newPolicy, description: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-20"
placeholder="Beschreibung der Löschfrist..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Datenkategorie *</label>
<input
type="text"
value={newPolicy.data_category}
onChange={(e) => setNewPolicy({ ...newPolicy, data_category: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. Nutzerdaten, Logs, Rechnungen"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select
value={newPolicy.status}
onChange={(e) => setNewPolicy({ ...newPolicy, status: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="draft">Entwurf</option>
<option value="active">Aktiv</option>
<option value="archived">Archiviert</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufbewahrungsfrist (Tage)</label>
<input
type="number"
value={newPolicy.retention_period_days}
onChange={(e) => setNewPolicy({ ...newPolicy, retention_period_days: parseInt(e.target.value) || 0 })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="365"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufbewahrungsfrist (Text)</label>
<input
type="text"
value={newPolicy.retention_period_text}
onChange={(e) => setNewPolicy({ ...newPolicy, retention_period_text: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. 3 Jahre, 10 Jahre nach Vertragsende"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage</label>
<select
value={newPolicy.legal_basis}
onChange={(e) => setNewPolicy({ ...newPolicy, legal_basis: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="legal_requirement">Gesetzliche Pflicht</option>
<option value="consent">Einwilligung</option>
<option value="legitimate_interest">Berechtigtes Interesse</option>
<option value="contract">Vertragserfüllung</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Gesetzliche Referenz</label>
<input
type="text"
value={newPolicy.legal_reference}
onChange={(e) => setNewPolicy({ ...newPolicy, legal_reference: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. § 147 AO, § 257 HGB"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Löschmethode</label>
<select
value={newPolicy.deletion_method}
onChange={(e) => setNewPolicy({ ...newPolicy, deletion_method: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="automatic">Automatische Löschung</option>
<option value="manual">Manuelle Löschung</option>
<option value="anonymization">Anonymisierung</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Löschprozedur</label>
<input
type="text"
value={newPolicy.deletion_procedure}
onChange={(e) => setNewPolicy({ ...newPolicy, deletion_procedure: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. Cron-Job, Skript"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verantwortliche Person</label>
<input
type="text"
value={newPolicy.responsible_person}
onChange={(e) => setNewPolicy({ ...newPolicy, responsible_person: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="Name"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Abteilung</label>
<input
type="text"
value={newPolicy.responsible_department}
onChange={(e) => setNewPolicy({ ...newPolicy, responsible_department: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. IT, Datenschutz"
/>
</div>
</div>
</div>
<div className="p-6 border-t border-slate-200 flex justify-end gap-3">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Abbrechen
</button>
<button
onClick={createPolicy}
disabled={!newPolicy.name || !newPolicy.data_category}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Löschfrist anlegen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,202 +0,0 @@
'use client'
/**
* DSGVO Dashboard - Übersicht aller Datenschutz-Module
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
interface ModuleCard {
id: string
title: string
description: string
href: string
icon: string
status: 'active' | 'coming_soon'
stats?: {
label: string
value: string | number
}
}
const modules: ModuleCard[] = [
{
id: 'consent',
title: 'Consent Management',
description: 'Dokumente, Versionen und Einwilligungen verwalten',
href: '/dsgvo/consent',
icon: '📋',
status: 'active',
},
{
id: 'dsr',
title: 'Betroffenenrechte (DSR)',
description: 'Art. 15-22 DSGVO: Auskunft, Löschung, Berichtigung',
href: '/dsgvo/dsr',
icon: '👤',
status: 'active',
},
{
id: 'einwilligungen',
title: 'Einwilligungen',
description: 'Übersicht aller erteilten Einwilligungen',
href: '/dsgvo/einwilligungen',
icon: '✅',
status: 'active',
},
{
id: 'vvt',
title: 'Verarbeitungsverzeichnis',
description: 'Art. 30 DSGVO: Dokumentation aller Verarbeitungstätigkeiten',
href: '/dsgvo/vvt',
icon: '📑',
status: 'active',
},
{
id: 'dsfa',
title: 'Datenschutz-Folgenabschätzung',
description: 'Art. 35 DSGVO: Risikobewertung für Verarbeitungen',
href: '/dsgvo/dsfa',
icon: '⚠️',
status: 'active',
},
{
id: 'tom',
title: 'TOM',
description: 'Art. 32 DSGVO: Technische und Organisatorische Maßnahmen',
href: '/dsgvo/tom',
icon: '🔒',
status: 'active',
},
{
id: 'loeschfristen',
title: 'Löschfristen',
description: 'Art. 17 DSGVO: Aufbewahrungsfristen und Löschkonzept',
href: '/dsgvo/loeschfristen',
icon: '🗑️',
status: 'active',
},
{
id: 'advisory-board',
title: 'Advisory Board',
description: 'KI-Use-Case Machbarkeits- und Compliance-Pruefung',
href: '/dsgvo/advisory-board',
icon: '🎯',
status: 'active',
},
]
export default function DSGVODashboard() {
const [stats, setStats] = useState<Record<string, any>>({})
const [loading, setLoading] = useState(true)
useEffect(() => {
// Load stats from SDK
async function loadStats() {
try {
const res = await fetch('/sdk/v1/dsgvo/stats', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (res.ok) {
const data = await res.json()
setStats(data)
}
} catch (err) {
console.error('Failed to load DSGVO stats:', err)
} finally {
setLoading(false)
}
}
loadStats()
}, [])
return (
<div className="space-y-6">
<PagePurpose
title="DSGVO Compliance"
purpose="Zentrale Übersicht aller DSGVO-Module für die vollständige Datenschutz-Konformität. Hier verwalten Sie Verarbeitungsverzeichnis, Betroffenenrechte, technische Maßnahmen und Löschfristen."
audience={['Datenschutzbeauftragter', 'Compliance Officer', 'IT-Leitung']}
gdprArticles={['Art. 15-22', 'Art. 30', 'Art. 32', 'Art. 35']}
collapsible={true}
defaultCollapsed={true}
/>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-2xl font-bold text-slate-800">
{loading ? '--' : (stats.processing_activities || 0)}
</div>
<div className="text-sm text-slate-500">Verarbeitungstätigkeiten</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-2xl font-bold text-slate-800">
{loading ? '--' : (stats.open_dsrs || 0)}
</div>
<div className="text-sm text-slate-500">Offene DSR-Anfragen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-2xl font-bold text-slate-800">
{loading ? '--' : (stats.toms_implemented || 0)}
</div>
<div className="text-sm text-slate-500">TOM implementiert</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-2xl font-bold text-slate-800">
{loading ? '--' : (stats.retention_policies || 0)}
</div>
<div className="text-sm text-slate-500">Löschfristen definiert</div>
</div>
</div>
{/* Module Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{modules.map((module) => (
<Link
key={module.id}
href={module.href}
className="group bg-white rounded-xl border border-slate-200 p-6 shadow-sm hover:shadow-md hover:border-primary-300 transition-all"
>
<div className="flex items-start gap-4">
<div className="text-3xl">{module.icon}</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-slate-800 group-hover:text-primary-600 transition-colors">
{module.title}
</h3>
{module.status === 'coming_soon' && (
<span className="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded">
Coming Soon
</span>
)}
</div>
<p className="text-sm text-slate-500 mt-1">{module.description}</p>
{module.stats && (
<div className="mt-3 text-sm">
<span className="text-slate-400">{module.stats.label}:</span>{' '}
<span className="text-slate-700 font-medium">{module.stats.value}</span>
</div>
)}
</div>
</div>
</Link>
))}
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<h4 className="text-blue-700 font-medium mb-2">SDK-Integration aktiv</h4>
<p className="text-sm text-blue-600">
Die DSGVO-Module sind in das AI Compliance SDK integriert.
Alle Datenschutz-Funktionen sind über eine einheitliche API verfügbar
und können von externen Systemen genutzt werden.
</p>
</div>
</div>
)
}

View File

@@ -1,602 +0,0 @@
'use client'
/**
* TOM - Technische und Organisatorische Maßnahmen
*
* Art. 32 DSGVO - Sicherheit der Verarbeitung
*
* Migriert auf SDK API: /sdk/v1/dsgvo/tom
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface TOM {
id: string
tenant_id: string
namespace_id?: string
category: string
subcategory?: string
name: string
description: string
type: string // technical, organizational
implementation_status: string // planned, in_progress, implemented, verified, not_applicable
implemented_at?: string
verified_at?: string
verified_by?: string
effectiveness_rating?: string // low, medium, high
documentation?: string
responsible_person: string
responsible_department: string
review_frequency: string // monthly, quarterly, annually
last_review_at?: string
next_review_at?: string
related_controls?: string[]
created_at: string
updated_at: string
}
interface CategoryGroup {
id: string
title: string
article: string
description: string
toms: TOM[]
}
const CATEGORY_META: Record<string, { title: string; article: string; description: string }> = {
access_control: {
title: 'Zugriffskontrolle',
article: 'Art. 32 Abs. 1 lit. b',
description: 'Fähigkeit, Vertraulichkeit und Integrität auf Dauer sicherzustellen'
},
encryption: {
title: 'Verschlüsselung',
article: 'Art. 32 Abs. 1 lit. a',
description: 'Pseudonymisierung und Verschlüsselung personenbezogener Daten'
},
pseudonymization: {
title: 'Pseudonymisierung',
article: 'Art. 32 Abs. 1 lit. a',
description: 'Verarbeitung ohne Zuordnung zu identifizierter Person'
},
availability: {
title: 'Verfügbarkeit & Belastbarkeit',
article: 'Art. 32 Abs. 1 lit. b',
description: 'Fähigkeit, Verfügbarkeit und Belastbarkeit der Systeme sicherzustellen'
},
resilience: {
title: 'Wiederherstellung',
article: 'Art. 32 Abs. 1 lit. c',
description: 'Rasche Wiederherstellung nach physischem oder technischem Zwischenfall'
},
monitoring: {
title: 'Protokollierung & Audit-Trail',
article: 'Art. 32 Abs. 2',
description: 'Nachweis der Einhaltung durch Protokollierung'
},
incident_response: {
title: 'Incident Response',
article: 'Art. 33/34',
description: 'Meldung von Verletzungen des Schutzes personenbezogener Daten'
},
review: {
title: 'Regelmäßige Überprüfung',
article: 'Art. 32 Abs. 1 lit. d',
description: 'Verfahren zur regelmäßigen Überprüfung, Bewertung und Evaluierung'
}
}
export default function TOMPage() {
const [toms, setToms] = useState<TOM[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedCategory, setExpandedCategory] = useState<string | null>('access_control')
const [showCreateModal, setShowCreateModal] = useState(false)
const [newTom, setNewTom] = useState({
category: 'access_control',
subcategory: '',
name: '',
description: '',
type: 'technical',
implementation_status: 'planned',
effectiveness_rating: 'medium',
documentation: '',
responsible_person: '',
responsible_department: '',
review_frequency: 'quarterly'
})
useEffect(() => {
loadTOMs()
}, [])
async function loadTOMs() {
setLoading(true)
setError(null)
try {
const res = await fetch('/sdk/v1/dsgvo/tom', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
setToms(data.toms || [])
} catch (err) {
console.error('Failed to load TOMs:', err)
setError('Fehler beim Laden der TOMs')
} finally {
setLoading(false)
}
}
async function createTOM() {
try {
const res = await fetch('/sdk/v1/dsgvo/tom', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(newTom)
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
setShowCreateModal(false)
setNewTom({
category: 'access_control',
subcategory: '',
name: '',
description: '',
type: 'technical',
implementation_status: 'planned',
effectiveness_rating: 'medium',
documentation: '',
responsible_person: '',
responsible_department: '',
review_frequency: 'quarterly'
})
loadTOMs()
} catch (err) {
console.error('Failed to create TOM:', err)
alert('Fehler beim Erstellen der Maßnahme')
}
}
async function exportTOMs(format: 'csv' | 'json') {
try {
const res = await fetch(`/sdk/v1/dsgvo/export/tom?format=${format}`, {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `tom-export.${format}`
a.click()
window.URL.revokeObjectURL(url)
} catch (err) {
console.error('Export failed:', err)
alert('Export fehlgeschlagen')
}
}
// Group TOMs by category
const categoryGroups: CategoryGroup[] = Object.entries(CATEGORY_META).map(([id, meta]) => ({
id,
...meta,
toms: toms.filter(t => t.category === id)
})).filter(group => group.toms.length > 0 || group.id === expandedCategory)
const getStatusBadge = (status: string) => {
switch (status) {
case 'implemented':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Umgesetzt</span>
case 'verified':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800">Verifiziert</span>
case 'in_progress':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">In Arbeit</span>
case 'planned':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Geplant</span>
case 'not_applicable':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">N/A</span>
default:
return null
}
}
const getTypeBadge = (type: string) => {
if (type === 'technical') {
return <span className="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">Technisch</span>
}
return <span className="px-2 py-0.5 rounded text-xs bg-purple-50 text-purple-700">Organisatorisch</span>
}
const calculateCategoryScore = (categoryToms: TOM[]) => {
if (categoryToms.length === 0) return 0
const total = categoryToms.length
const implemented = categoryToms.filter(t => t.implementation_status === 'implemented' || t.implementation_status === 'verified').length
const inProgress = categoryToms.filter(t => t.implementation_status === 'in_progress').length
return Math.round(((implemented + inProgress * 0.5) / total) * 100)
}
const calculateOverallScore = () => {
if (toms.length === 0) return 0
let total = toms.length
let score = 0
toms.forEach(t => {
if (t.implementation_status === 'implemented' || t.implementation_status === 'verified') score += 1
else if (t.implementation_status === 'in_progress') score += 0.5
})
return Math.round((score / total) * 100)
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-500">Lade TOMs...</div>
</div>
)
}
return (
<div>
<PagePurpose
title="Technische & Organisatorische Maßnahmen (TOMs)"
purpose="Dokumentation aller Sicherheitsmaßnahmen gemäß Art. 32 DSGVO. Diese Seite dient als Nachweis für Auditoren und den DSB."
audience={['DSB', 'IT-Sicherheit', 'Auditoren', 'Geschäftsführung']}
gdprArticles={['Art. 32 (Sicherheit der Verarbeitung)']}
architecture={{
services: ['AI Compliance SDK (Go)', 'PostgreSQL'],
databases: ['PostgreSQL (verschlüsselt)'],
}}
relatedPages={[
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management-System' },
{ name: 'VVT', href: '/dsgvo/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'DSFA', href: '/dsgvo/dsfa', description: 'Datenschutz-Folgenabschätzung' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4 text-red-700">
{error}
</div>
)}
{/* Header Actions */}
<div className="flex items-center justify-between mb-6">
<div></div>
<div className="flex items-center gap-3">
<button
onClick={() => exportTOMs('csv')}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
CSV Export
</button>
<button
onClick={() => exportTOMs('json')}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
JSON Export
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
+ Neue Maßnahme
</button>
</div>
</div>
{/* Overall Score */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">TOM-Umsetzungsgrad</h2>
<p className="text-sm text-slate-500 mt-1">Gesamtfortschritt aller technischen und organisatorischen Maßnahmen</p>
</div>
<div className="text-right">
<div className={`text-4xl font-bold ${calculateOverallScore() >= 80 ? 'text-green-600' : calculateOverallScore() >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
{calculateOverallScore()}%
</div>
<div className="text-sm text-slate-500">{toms.length} Maßnahmen</div>
</div>
</div>
<div className="mt-4 h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${calculateOverallScore() >= 80 ? 'bg-green-500' : calculateOverallScore() >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${calculateOverallScore()}%` }}
/>
</div>
</div>
{/* TOM Categories */}
{categoryGroups.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
<div className="text-slate-400 text-4xl mb-4">🔒</div>
<h3 className="text-lg font-medium text-slate-800 mb-2">Keine Maßnahmen erfasst</h3>
<p className="text-slate-500 mb-4">Legen Sie technische und organisatorische Maßnahmen an.</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
Erste Maßnahme anlegen
</button>
</div>
) : (
<div className="space-y-4">
{categoryGroups.map((category) => (
<div key={category.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setExpandedCategory(expandedCategory === category.id ? null : category.id)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center text-lg font-bold ${
calculateCategoryScore(category.toms) >= 80 ? 'bg-green-100 text-green-700' :
calculateCategoryScore(category.toms) >= 50 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{calculateCategoryScore(category.toms)}%
</div>
<div className="text-left">
<h3 className="font-semibold text-slate-900">{category.title}</h3>
<p className="text-sm text-slate-500">{category.article} - {category.description}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500">{category.toms.length} Maßnahmen</span>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedCategory === category.id ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{expandedCategory === category.id && (
<div className="px-6 pb-6 border-t border-slate-100">
<div className="mt-4 space-y-3">
{category.toms.length === 0 ? (
<div className="p-4 bg-slate-50 rounded-lg text-center text-slate-500">
Keine Maßnahmen in dieser Kategorie
</div>
) : (
category.toms.map((tom) => (
<div key={tom.id} className="p-4 bg-slate-50 rounded-lg">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<h4 className="font-medium text-slate-900">{tom.name}</h4>
{getTypeBadge(tom.type)}
</div>
{getStatusBadge(tom.implementation_status)}
</div>
<p className="text-sm text-slate-600 mb-3">{tom.description}</p>
<div className="flex flex-wrap gap-4 text-xs text-slate-500">
{tom.documentation && (
<span>Nachweis: <span className="font-mono">{tom.documentation}</span></span>
)}
{tom.responsible_person && (
<span>Verantwortlich: {tom.responsible_person}</span>
)}
{tom.responsible_department && (
<span>Abteilung: {tom.responsible_department}</span>
)}
{tom.last_review_at && (
<span>Letzte Prüfung: {new Date(tom.last_review_at).toLocaleDateString('de-DE')}</span>
)}
{tom.review_frequency && (
<span className="capitalize">
Prüfung: {tom.review_frequency === 'monthly' ? 'Monatlich' : tom.review_frequency === 'quarterly' ? 'Quartalsweise' : 'Jährlich'}
</span>
)}
</div>
{tom.effectiveness_rating && (
<div className="mt-2">
<span className={`text-xs px-2 py-0.5 rounded ${
tom.effectiveness_rating === 'high' ? 'bg-green-100 text-green-700' :
tom.effectiveness_rating === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
Wirksamkeit: {tom.effectiveness_rating === 'high' ? 'Hoch' : tom.effectiveness_rating === 'medium' ? 'Mittel' : 'Niedrig'}
</span>
</div>
)}
</div>
))
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Info */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-purple-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-semibold text-purple-900">Dokumentationspflicht</h4>
<p className="text-sm text-purple-800 mt-1">
Gemäß Art. 32 Abs. 1 DSGVO müssen geeignete technische und organisatorische Maßnahmen
implementiert werden, um ein dem Risiko angemessenes Schutzniveau zu gewährleisten.
Diese Dokumentation dient als Nachweis für Aufsichtsbehörden.
</p>
</div>
</div>
</div>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<h3 className="text-lg font-semibold text-slate-900">Neue Maßnahme anlegen</h3>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie</label>
<select
value={newTom.category}
onChange={(e) => setNewTom({ ...newTom, category: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
{Object.entries(CATEGORY_META).map(([id, meta]) => (
<option key={id} value={id}>{meta.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={newTom.type}
onChange={(e) => setNewTom({ ...newTom, type: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="technical">Technisch</option>
<option value="organizational">Organisatorisch</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newTom.name}
onChange={(e) => setNewTom({ ...newTom, name: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. TLS 1.3 für alle Verbindungen"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung *</label>
<textarea
value={newTom.description}
onChange={(e) => setNewTom({ ...newTom, description: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-24"
placeholder="Detaillierte Beschreibung der Maßnahme..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select
value={newTom.implementation_status}
onChange={(e) => setNewTom({ ...newTom, implementation_status: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="planned">Geplant</option>
<option value="in_progress">In Arbeit</option>
<option value="implemented">Umgesetzt</option>
<option value="verified">Verifiziert</option>
<option value="not_applicable">Nicht zutreffend</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Wirksamkeit</label>
<select
value={newTom.effectiveness_rating}
onChange={(e) => setNewTom({ ...newTom, effectiveness_rating: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verantwortliche Person</label>
<input
type="text"
value={newTom.responsible_person}
onChange={(e) => setNewTom({ ...newTom, responsible_person: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="Name der verantwortlichen Person"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Abteilung</label>
<input
type="text"
value={newTom.responsible_department}
onChange={(e) => setNewTom({ ...newTom, responsible_department: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. IT-Abteilung"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Prüfungsintervall</label>
<select
value={newTom.review_frequency}
onChange={(e) => setNewTom({ ...newTom, review_frequency: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="monthly">Monatlich</option>
<option value="quarterly">Quartalsweise</option>
<option value="annually">Jährlich</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nachweis/Dokumentation</label>
<input
type="text"
value={newTom.documentation}
onChange={(e) => setNewTom({ ...newTom, documentation: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. SSL Labs Report, Config-Datei"
/>
</div>
</div>
</div>
<div className="p-6 border-t border-slate-200 flex justify-end gap-3">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Abbrechen
</button>
<button
onClick={createTOM}
disabled={!newTom.name || !newTom.description}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Maßnahme anlegen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,597 +0,0 @@
'use client'
/**
* VVT - Verarbeitungsverzeichnis
*
* Art. 30 DSGVO - Verzeichnis von Verarbeitungstaetigkeiten
* Integriert mit AI Compliance SDK
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface ProcessingActivity {
id: string
tenant_id: string
name: string
description: string
purpose: string
legal_basis: string
legal_basis_details: string
data_categories: string[]
data_subject_categories: string[]
recipients: string[]
third_country_transfer: boolean
transfer_safeguards: string
retention_period: string
dsfa_required: boolean
responsible_person: string
responsible_department: string
systems: string[]
status: string
created_at: string
updated_at: string
}
const LEGAL_BASES = [
{ value: 'consent', label: 'Art. 6 Abs. 1 lit. a - Einwilligung' },
{ value: 'contract', label: 'Art. 6 Abs. 1 lit. b - Vertragserfüllung' },
{ value: 'legal_obligation', label: 'Art. 6 Abs. 1 lit. c - Rechtliche Verpflichtung' },
{ value: 'vital_interests', label: 'Art. 6 Abs. 1 lit. d - Lebenswichtige Interessen' },
{ value: 'public_interest', label: 'Art. 6 Abs. 1 lit. e - Öffentliches Interesse' },
{ value: 'legitimate_interests', label: 'Art. 6 Abs. 1 lit. f - Berechtigtes Interesse' },
]
export default function VVTPage() {
const [activities, setActivities] = useState<ProcessingActivity[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedActivity, setExpandedActivity] = useState<string | null>(null)
const [filterStatus, setFilterStatus] = useState<string>('all')
const [showCreateModal, setShowCreateModal] = useState(false)
const [newActivity, setNewActivity] = useState<Partial<ProcessingActivity>>({
name: '',
purpose: '',
legal_basis: 'contract',
legal_basis_details: '',
data_categories: [],
data_subject_categories: [],
recipients: [],
third_country_transfer: false,
retention_period: '',
responsible_person: '',
responsible_department: '',
systems: [],
status: 'draft',
})
useEffect(() => {
loadActivities()
}, [])
async function loadActivities() {
setLoading(true)
setError(null)
try {
const res = await fetch('/sdk/v1/dsgvo/processing-activities', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (res.ok) {
const data = await res.json()
setActivities(data.processing_activities || [])
} else {
const errorData = await res.json().catch(() => ({}))
setError(errorData.error || 'Fehler beim Laden')
}
} catch (err) {
setError('Verbindungsfehler zum SDK')
} finally {
setLoading(false)
}
}
async function createActivity() {
try {
const res = await fetch('/sdk/v1/dsgvo/processing-activities', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(newActivity),
})
if (res.ok) {
setShowCreateModal(false)
setNewActivity({
name: '',
purpose: '',
legal_basis: 'contract',
data_categories: [],
status: 'draft',
})
loadActivities()
} else {
const errorData = await res.json().catch(() => ({}))
alert(errorData.error || 'Fehler beim Erstellen')
}
} catch (err) {
alert('Verbindungsfehler')
}
}
async function deleteActivity(id: string) {
if (!confirm('Verarbeitungstätigkeit wirklich löschen?')) return
try {
const res = await fetch(`/sdk/v1/dsgvo/processing-activities/${id}`, {
method: 'DELETE',
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
})
if (res.ok) {
loadActivities()
}
} catch (err) {
alert('Fehler beim Löschen')
}
}
async function exportVVT(format: 'csv' | 'json') {
window.open(`/sdk/v1/dsgvo/export/vvt?format=${format}`, '_blank')
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Aktiv</span>
case 'draft':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">Entwurf</span>
case 'under_review':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">In Prüfung</span>
case 'archived':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Archiviert</span>
default:
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">{status}</span>
}
}
const getLegalBasisLabel = (value: string) => {
const basis = LEGAL_BASES.find(b => b.value === value)
return basis?.label || value
}
const filteredActivities = filterStatus === 'all'
? activities
: activities.filter(a => a.status === filterStatus)
return (
<div>
<PagePurpose
title="Verarbeitungsverzeichnis (VVT)"
purpose="Verzeichnis aller Verarbeitungstätigkeiten gemäß Art. 30 DSGVO. Dokumentiert Zweck, Rechtsgrundlage, Kategorien und Löschfristen."
audience={['DSB', 'Auditoren', 'Aufsichtsbehörden']}
gdprArticles={['Art. 30 (Verzeichnis von Verarbeitungstätigkeiten)']}
architecture={{
services: ['AI Compliance SDK (Go)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'TOM', href: '/dsgvo/tom', description: 'Technische Maßnahmen' },
{ name: 'DSFA', href: '/dsgvo/dsfa', description: 'Datenschutz-Folgenabschätzung' },
{ name: 'Löschfristen', href: '/dsgvo/loeschfristen', description: 'Aufbewahrungsfristen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Header */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Verarbeitungstätigkeiten</h2>
<p className="text-sm text-slate-500 mt-1">{activities.length} dokumentierte Tätigkeiten</p>
</div>
<div className="flex gap-2">
<button
onClick={() => exportVVT('csv')}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200"
>
CSV Export
</button>
<button
onClick={() => exportVVT('json')}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200"
>
JSON Export
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700"
>
+ Neue Tätigkeit
</button>
</div>
</div>
{/* Filter */}
<div className="flex gap-2">
{[
{ value: 'all', label: 'Alle' },
{ value: 'active', label: 'Aktiv' },
{ value: 'draft', label: 'Entwurf' },
{ value: 'under_review', label: 'In Prüfung' },
{ value: 'archived', label: 'Archiviert' },
].map(filter => (
<button
key={filter.value}
onClick={() => setFilterStatus(filter.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
filterStatus === filter.value
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{filter.label}
</button>
))}
</div>
</div>
{/* Loading / Error */}
{loading && (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<div className="animate-spin w-8 h-8 border-2 border-purple-600 border-t-transparent rounded-full mx-auto"></div>
<p className="mt-4 text-slate-500">Lade Verarbeitungstätigkeiten...</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
<p className="text-red-700">{error}</p>
<button onClick={loadActivities} className="mt-2 text-sm text-red-600 underline">
Erneut versuchen
</button>
</div>
)}
{/* Activities List */}
{!loading && !error && (
<div className="space-y-4">
{filteredActivities.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<p className="text-slate-500">Keine Verarbeitungstätigkeiten gefunden.</p>
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 text-purple-600 font-medium hover:underline"
>
Erste Tätigkeit anlegen
</button>
</div>
) : (
filteredActivities.map((activity) => (
<div key={activity.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setExpandedActivity(expandedActivity === activity.id ? null : activity.id)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="text-left">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-slate-900">{activity.name}</h3>
{getStatusBadge(activity.status)}
{activity.third_country_transfer && (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
Drittland-Transfer
</span>
)}
{activity.dsfa_required && (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
DSFA erforderlich
</span>
)}
</div>
<p className="text-sm text-slate-500 mt-1">{activity.purpose}</p>
</div>
</div>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedActivity === activity.id ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expandedActivity === activity.id && (
<div className="px-6 pb-6 border-t border-slate-100">
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Rechtsgrundlage</h4>
<p className="font-semibold text-slate-900">{getLegalBasisLabel(activity.legal_basis)}</p>
{activity.legal_basis_details && (
<p className="text-sm text-slate-600">{activity.legal_basis_details}</p>
)}
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Datenkategorien</h4>
<div className="flex flex-wrap gap-2">
{(activity.data_categories || []).map((cat, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-700 rounded text-sm">
{cat}
</span>
))}
{(!activity.data_categories || activity.data_categories.length === 0) && (
<span className="text-slate-400 text-sm">Keine angegeben</span>
)}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Betroffene Kategorien</h4>
<div className="flex flex-wrap gap-2">
{(activity.data_subject_categories || []).map((cat, idx) => (
<span key={idx} className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-sm">
{cat}
</span>
))}
{(!activity.data_subject_categories || activity.data_subject_categories.length === 0) && (
<span className="text-slate-400 text-sm">Keine angegeben</span>
)}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Empfänger</h4>
{activity.recipients && activity.recipients.length > 0 ? (
<ul className="text-sm text-slate-700 list-disc list-inside">
{activity.recipients.map((rec, idx) => (
<li key={idx}>{rec}</li>
))}
</ul>
) : (
<span className="text-slate-400 text-sm">Keine externen Empfänger</span>
)}
</div>
</div>
{/* Right Column */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Löschfrist</h4>
<p className="text-slate-700">{activity.retention_period || 'Nicht definiert'}</p>
</div>
{activity.third_country_transfer && activity.transfer_safeguards && (
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Drittland-Schutzmaßnahmen</h4>
<p className="text-slate-700">{activity.transfer_safeguards}</p>
</div>
)}
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Verantwortlich</h4>
<p className="text-slate-700">
{activity.responsible_person || 'k.A.'}
{activity.responsible_department && ` (${activity.responsible_department})`}
</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Systeme</h4>
<div className="flex flex-wrap gap-2">
{(activity.systems || []).map((sys, idx) => (
<span key={idx} className="px-2 py-1 bg-green-100 text-green-700 rounded text-sm">
{sys}
</span>
))}
{(!activity.systems || activity.systems.length === 0) && (
<span className="text-slate-400 text-sm">Keine angegeben</span>
)}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Erstellt / Aktualisiert</h4>
<p className="text-slate-700 text-sm">
{new Date(activity.created_at).toLocaleDateString('de-DE')} / {new Date(activity.updated_at).toLocaleDateString('de-DE')}
</p>
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-100 flex gap-2">
<button className="px-3 py-1.5 text-sm text-purple-600 hover:text-purple-700 font-medium">
Bearbeiten
</button>
<button
onClick={() => deleteActivity(activity.id)}
className="px-3 py-1.5 text-sm text-red-600 hover:text-red-700"
>
Löschen
</button>
</div>
</div>
)}
</div>
))
)}
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neue Verarbeitungstätigkeit</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newActivity.name || ''}
onChange={(e) => setNewActivity({ ...newActivity, name: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="z.B. Benutzerverwaltung"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Zweck der Verarbeitung *</label>
<textarea
value={newActivity.purpose || ''}
onChange={(e) => setNewActivity({ ...newActivity, purpose: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={2}
placeholder="Beschreiben Sie den Zweck der Datenverarbeitung"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage *</label>
<select
value={newActivity.legal_basis || 'contract'}
onChange={(e) => setNewActivity({ ...newActivity, legal_basis: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
{LEGAL_BASES.map(basis => (
<option key={basis.value} value={basis.value}>{basis.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Details zur Rechtsgrundlage</label>
<input
type="text"
value={newActivity.legal_basis_details || ''}
onChange={(e) => setNewActivity({ ...newActivity, legal_basis_details: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Weitere Details zur Begründung"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Datenkategorien (kommasepariert)</label>
<input
type="text"
value={(newActivity.data_categories || []).join(', ')}
onChange={(e) => setNewActivity({ ...newActivity, data_categories: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Name, E-Mail, Adresse"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Betroffene (kommasepariert)</label>
<input
type="text"
value={(newActivity.data_subject_categories || []).join(', ')}
onChange={(e) => setNewActivity({ ...newActivity, data_subject_categories: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Kunden, Mitarbeiter, Lieferanten"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufbewahrungsfrist</label>
<input
type="text"
value={newActivity.retention_period || ''}
onChange={(e) => setNewActivity({ ...newActivity, retention_period: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="z.B. 10 Jahre (§ 147 AO)"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verantwortliche Person</label>
<input
type="text"
value={newActivity.responsible_person || ''}
onChange={(e) => setNewActivity({ ...newActivity, responsible_person: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Abteilung</label>
<input
type="text"
value={newActivity.responsible_department || ''}
onChange={(e) => setNewActivity({ ...newActivity, responsible_department: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="third_country"
checked={newActivity.third_country_transfer || false}
onChange={(e) => setNewActivity({ ...newActivity, third_country_transfer: e.target.checked })}
className="rounded"
/>
<label htmlFor="third_country" className="text-sm text-slate-700">Drittland-Transfer</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="dsfa_required"
checked={newActivity.dsfa_required || false}
onChange={(e) => setNewActivity({ ...newActivity, dsfa_required: e.target.checked })}
className="rounded"
/>
<label htmlFor="dsfa_required" className="text-sm text-slate-700">DSFA erforderlich</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 text-slate-700 hover:bg-slate-100 rounded-lg text-sm font-medium"
>
Abbrechen
</button>
<button
onClick={createActivity}
disabled={!newActivity.name || !newActivity.purpose}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 disabled:opacity-50"
>
Erstellen
</button>
</div>
</div>
</div>
)}
{/* Info */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-purple-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-semibold text-purple-900">Pflicht zur Führung</h4>
<p className="text-sm text-purple-800 mt-1">
Gemäß Art. 30 DSGVO ist jeder Verantwortliche verpflichtet, ein Verzeichnis aller
Verarbeitungstätigkeiten zu führen. Dieses Verzeichnis muss der Aufsichtsbehörde
auf Anfrage zur Verfügung gestellt werden.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,76 +0,0 @@
'use client'
import { Suspense } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { getModuleByHref } from '@/lib/navigation'
import { CompanionDashboard } from '@/components/companion/CompanionDashboard'
import { GraduationCap } from 'lucide-react'
function LoadingFallback() {
return (
<div className="space-y-6">
{/* Header Skeleton */}
<div className="flex items-center justify-between">
<div className="h-12 w-80 bg-slate-200 rounded-xl animate-pulse" />
<div className="flex gap-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-10 w-10 bg-slate-200 rounded-lg animate-pulse" />
))}
</div>
</div>
{/* Phase Timeline Skeleton */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="h-4 w-24 bg-slate-200 rounded mb-4 animate-pulse" />
<div className="flex gap-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-10 h-10 bg-slate-200 rounded-full animate-pulse" />
{i < 5 && <div className="w-8 h-1 bg-slate-200 animate-pulse" />}
</div>
))}
</div>
</div>
{/* Stats Skeleton */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white border border-slate-200 rounded-xl p-4">
<div className="h-4 w-16 bg-slate-200 rounded mb-2 animate-pulse" />
<div className="h-8 w-12 bg-slate-200 rounded animate-pulse" />
</div>
))}
</div>
{/* Content Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white border border-slate-200 rounded-xl p-6 h-64 animate-pulse" />
<div className="bg-white border border-slate-200 rounded-xl p-6 h-64 animate-pulse" />
</div>
</div>
)
}
export default function CompanionPage() {
const moduleInfo = getModuleByHref('/education/companion')
return (
<div className="space-y-6">
{/* Page Purpose Header */}
{moduleInfo && (
<PagePurpose
title={moduleInfo.module.name}
purpose={moduleInfo.module.purpose}
audience={moduleInfo.module.audience}
collapsible={true}
defaultCollapsed={true}
/>
)}
{/* Main Companion Dashboard */}
<Suspense fallback={<LoadingFallback />}>
<CompanionDashboard />
</Suspense>
</div>
)
}

View File

@@ -1,675 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
// Types
interface WizardStep {
number: number
id: string
title: string
subtitle: string
description: string
icon: string
is_required: boolean
is_completed: boolean
}
interface FormData {
[key: string]: any
}
const API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8080'
// Step icons mapping
const stepIcons: Record<string, React.ReactNode> = {
'document-text': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
'academic-cap': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l9-5-9-5-9 5 9 5z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z" />
</svg>
),
'server': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
</svg>
),
'document-report': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
'currency-euro': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.121 15.536c-1.171 1.952-3.07 1.952-4.242 0-1.172-1.953-1.172-5.119 0-7.072 1.171-1.952 3.07-1.952 4.242 0M8 10.5h4m-4 3h4m9-1.5a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
'calculator': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
),
'calendar': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
'document-download': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
}
// Default wizard steps
const defaultSteps: WizardStep[] = [
{ number: 1, id: 'foerderprogramm', title: 'Foerderprogramm', subtitle: 'Programm & Grunddaten', description: 'Waehlen Sie das Foerderprogramm', icon: 'document-text', is_required: true, is_completed: false },
{ number: 2, id: 'schulinformationen', title: 'Schulinformationen', subtitle: 'Schule & Traeger', description: 'Angaben zur Schule', icon: 'academic-cap', is_required: true, is_completed: false },
{ number: 3, id: 'bestandsaufnahme', title: 'IT-Bestand', subtitle: 'Aktuelle Infrastruktur', description: 'IT-Bestandsaufnahme', icon: 'server', is_required: true, is_completed: false },
{ number: 4, id: 'projektbeschreibung', title: 'Projektbeschreibung', subtitle: 'Ziele & Didaktik', description: 'Projektziele beschreiben', icon: 'document-report', is_required: true, is_completed: false },
{ number: 5, id: 'investitionen', title: 'Investitionen', subtitle: 'Kostenaufstellung', description: 'Geplante Anschaffungen', icon: 'currency-euro', is_required: true, is_completed: false },
{ number: 6, id: 'finanzierungsplan', title: 'Finanzierung', subtitle: 'Budget & Eigenanteil', description: 'Finanzierungsplan', icon: 'calculator', is_required: true, is_completed: false },
{ number: 7, id: 'zeitplan', title: 'Zeitplan', subtitle: 'Laufzeit & Meilensteine', description: 'Projektlaufzeit planen', icon: 'calendar', is_required: true, is_completed: false },
{ number: 8, id: 'abschluss', title: 'Abschluss', subtitle: 'Dokumente & Pruefung', description: 'Zusammenfassung', icon: 'document-download', is_required: true, is_completed: false },
]
export default function FoerderantragWizardPage() {
const params = useParams()
const router = useRouter()
const applicationId = params.applicationId as string
const [currentStep, setCurrentStep] = useState(1)
const [steps, setSteps] = useState<WizardStep[]>(defaultSteps)
const [formData, setFormData] = useState<FormData>({})
const [isSaving, setIsSaving] = useState(false)
const [showAssistant, setShowAssistant] = useState(false)
const [assistantMessage, setAssistantMessage] = useState('')
const [assistantHistory, setAssistantHistory] = useState<{ role: string; content: string }[]>([])
const [isDemo, setIsDemo] = useState(false)
useEffect(() => {
// Check if this is a demo application
if (applicationId.startsWith('demo-')) {
setIsDemo(true)
}
loadApplication()
}, [applicationId])
const loadApplication = async () => {
// In production, load from API
// For demo, use mock data
}
const handleFieldChange = (fieldId: string, value: any) => {
setFormData(prev => ({
...prev,
[`step_${currentStep}`]: {
...prev[`step_${currentStep}`],
[fieldId]: value,
},
}))
}
const handleSaveStep = async () => {
setIsSaving(true)
try {
// Save step data
// Update step completion status
setSteps(prev => prev.map(s =>
s.number === currentStep ? { ...s, is_completed: true } : s
))
} finally {
setIsSaving(false)
}
}
const handleNextStep = async () => {
await handleSaveStep()
if (currentStep < 8) {
setCurrentStep(prev => prev + 1)
}
}
const handlePrevStep = () => {
if (currentStep > 1) {
setCurrentStep(prev => prev - 1)
}
}
const handleAskAssistant = async () => {
if (!assistantMessage.trim()) return
const userMessage = assistantMessage
setAssistantMessage('')
setAssistantHistory(prev => [...prev, { role: 'user', content: userMessage }])
// Simulate assistant response
setTimeout(() => {
const response = getAssistantResponse(userMessage, currentStep)
setAssistantHistory(prev => [...prev, { role: 'assistant', content: response }])
}, 1000)
}
const getAssistantResponse = (question: string, step: number): string => {
// Simple response logic - in production, this calls the LLM API
if (question.toLowerCase().includes('foerderquote')) {
return 'Die Foerderquote im DigitalPakt 2.0 betraegt in der Regel 90%. Das bedeutet, dass 10% der Kosten als Eigenanteil vom Schultraeger zu tragen sind. In einigen Bundeslaendern gibt es Sonderregelungen fuer finanzschwache Kommunen.'
}
if (question.toLowerCase().includes('mep') || question.toLowerCase().includes('medienentwicklungsplan')) {
return 'Der Medienentwicklungsplan (MEP) ist ein strategisches Dokument, das die paedagogischen und technischen Ziele der Schule fuer die Digitalisierung beschreibt. In den meisten Bundeslaendern ist ein MEP Voraussetzung fuer die Foerderung.'
}
if (question.toLowerCase().includes('foerderfahig')) {
return 'Foerderfahig sind unter anderem: Netzwerkinfrastruktur, WLAN, Praesentationstechnik, Endgeraete (mit Einschraenkungen), Server und lokale KI-Systeme. Nicht foerderfahig sind: Verbrauchsmaterial, laufende Betriebskosten und Cloud-Abonnements ohne lokale Alternative.'
}
return `Ich helfe Ihnen gerne bei Schritt ${step}. Haben Sie eine konkrete Frage zu den Feldern in diesem Abschnitt? Sie koennen mich auch nach Formulierungshilfen oder Erklaerungen zu Fachbegriffen fragen.`
}
const renderStepContent = () => {
const step = steps.find(s => s.number === currentStep)
if (!step) return null
switch (currentStep) {
case 1:
return <Step1Foerderprogramm formData={formData} onChange={handleFieldChange} />
case 2:
return <Step2Schulinformationen formData={formData} onChange={handleFieldChange} />
case 3:
return <Step3Bestandsaufnahme formData={formData} onChange={handleFieldChange} />
case 4:
return <Step4Projektbeschreibung formData={formData} onChange={handleFieldChange} />
case 5:
return <Step5Investitionen formData={formData} onChange={handleFieldChange} />
case 6:
return <Step6Finanzierungsplan formData={formData} onChange={handleFieldChange} />
case 7:
return <Step7Zeitplan formData={formData} onChange={handleFieldChange} />
case 8:
return <Step8Abschluss formData={formData} onChange={handleFieldChange} />
default:
return null
}
}
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-white border-b border-slate-200 sticky top-0 z-20">
<div className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href="/education/foerderantrag"
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
>
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="font-semibold text-slate-900">Foerderantrag bearbeiten</h1>
<p className="text-sm text-slate-500">
Schritt {currentStep} von {steps.length}: {steps.find(s => s.number === currentStep)?.title}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{isDemo && (
<span className="px-3 py-1 bg-amber-100 text-amber-700 text-sm font-medium rounded-full">
Demo-Modus
</span>
)}
<button
onClick={() => setShowAssistant(!showAssistant)}
className={`p-2 rounded-lg transition-colors ${showAssistant ? 'bg-blue-100 text-blue-600' : 'hover:bg-slate-100 text-slate-600'}`}
title="KI-Assistent"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</button>
<button
onClick={handleSaveStep}
disabled={isSaving}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg font-medium hover:bg-slate-200 disabled:opacity-50 transition-colors"
>
{isSaving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
{/* Progress Steps */}
<div className="px-6 pb-4 overflow-x-auto">
<div className="flex gap-1 min-w-max">
{steps.map((step) => (
<button
key={step.number}
onClick={() => setCurrentStep(step.number)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-all ${
currentStep === step.number
? 'bg-blue-600 text-white'
: step.is_completed
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
currentStep === step.number
? 'bg-white/20'
: step.is_completed
? 'bg-green-500 text-white'
: 'bg-slate-300 text-slate-600'
}`}>
{step.is_completed && currentStep !== step.number ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
) : (
step.number
)}
</span>
<span className="hidden md:block font-medium">{step.title}</span>
</button>
))}
</div>
</div>
</div>
{/* Main Content */}
<div className="flex">
{/* Form Area */}
<div className={`flex-1 p-6 transition-all ${showAssistant ? 'pr-96' : ''}`}>
<div className="max-w-3xl mx-auto">
{/* Step Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-blue-100 text-blue-600 flex items-center justify-center">
{stepIcons[steps.find(s => s.number === currentStep)?.icon || 'document-text']}
</div>
<div>
<h2 className="text-xl font-semibold text-slate-900">
{steps.find(s => s.number === currentStep)?.title}
</h2>
<p className="text-sm text-slate-500">
{steps.find(s => s.number === currentStep)?.description}
</p>
</div>
</div>
</div>
{/* Step Content */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
{renderStepContent()}
</div>
{/* Navigation */}
<div className="flex items-center justify-between mt-6">
<button
onClick={handlePrevStep}
disabled={currentStep === 1}
className="px-6 py-3 text-slate-600 hover:text-slate-900 disabled:opacity-50 disabled:cursor-not-allowed font-medium flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck
</button>
<button
onClick={handleNextStep}
className="px-6 py-3 bg-blue-600 text-white rounded-xl font-semibold hover:bg-blue-700 flex items-center gap-2 transition-colors"
>
{currentStep === 8 ? 'Abschliessen' : 'Weiter'}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
{/* Assistant Sidebar */}
{showAssistant && (
<div className="fixed right-0 top-0 h-full w-96 bg-white border-l border-slate-200 shadow-xl z-30 flex flex-col">
<div className="p-4 border-b border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-slate-900">KI-Assistent</h3>
<p className="text-xs text-slate-500">Ich helfe bei Fragen</p>
</div>
</div>
<button
onClick={() => setShowAssistant(false)}
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
>
<svg className="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Chat History */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{assistantHistory.length === 0 && (
<div className="text-center py-8">
<p className="text-slate-500 text-sm">
Stellen Sie mir Fragen zum aktuellen Schritt oder bitten Sie um Formulierungshilfen.
</p>
<div className="mt-4 space-y-2">
{['Was ist foerderfahig?', 'Erklaere die Foerderquote', 'Was ist ein MEP?'].map((q) => (
<button
key={q}
onClick={() => {
setAssistantMessage(q)
setTimeout(handleAskAssistant, 100)
}}
className="block w-full text-left px-3 py-2 bg-slate-100 rounded-lg text-sm text-slate-700 hover:bg-slate-200 transition-colors"
>
{q}
</button>
))}
</div>
</div>
)}
{assistantHistory.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] p-3 rounded-xl text-sm ${
msg.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-700'
}`}
>
{msg.content}
</div>
</div>
))}
</div>
{/* Input */}
<div className="p-4 border-t border-slate-200">
<div className="flex gap-2">
<input
type="text"
value={assistantMessage}
onChange={(e) => setAssistantMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAskAssistant()}
placeholder="Frage stellen..."
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleAskAssistant}
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}
// Step Components (simplified for now)
function Step1Foerderprogramm({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<p className="text-slate-600">
Die Grunddaten wurden bereits beim Erstellen des Antrags festgelegt.
Sie koennen diese hier bei Bedarf anpassen.
</p>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-700">
Klicken Sie auf "Weiter" um mit den Schulinformationen fortzufahren.
</p>
</div>
</div>
)
}
function Step2Schulinformationen({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Schulname *</label>
<input
type="text"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. Gymnasium am Beispielweg"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Schulnummer *</label>
<input
type="text"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. 12345"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Anzahl Schueler</label>
<input
type="number"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. 850"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Anzahl Lehrkraefte</label>
<input
type="number"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. 65"
/>
</div>
</div>
</div>
)
}
function Step3Bestandsaufnahme({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<input type="checkbox" id="has_wlan" className="w-4 h-4 rounded border-slate-300" />
<label htmlFor="has_wlan" className="text-sm text-slate-700">WLAN vorhanden</label>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Internet-Bandbreite</label>
<select className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option>Unter 16 Mbit/s</option>
<option>16-50 Mbit/s</option>
<option>50-100 Mbit/s</option>
<option>100-250 Mbit/s</option>
<option>Ueber 250 Mbit/s</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Vorhandene Endgeraete</label>
<input
type="number"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Anzahl"
/>
</div>
</div>
)
}
function Step4Projektbeschreibung({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Kurzbeschreibung *</label>
<textarea
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="Beschreiben Sie Ihr Projekt in 2-3 Saetzen..."
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Projektziele *</label>
<textarea
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="Welche konkreten Ziele verfolgen Sie?"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Paedagogisches Konzept *</label>
<textarea
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="Wie wird die Technik im Unterricht eingesetzt?"
/>
</div>
</div>
)
}
function Step5Investitionen({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<p className="text-slate-600">
Listen Sie alle geplanten Investitionen auf. Der Wizard berechnet automatisch die Summen.
</p>
<div className="border border-slate-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-slate-700">Beschreibung</th>
<th className="px-4 py-2 text-left font-medium text-slate-700">Anzahl</th>
<th className="px-4 py-2 text-left font-medium text-slate-700">Einzelpreis</th>
<th className="px-4 py-2 text-left font-medium text-slate-700">Gesamt</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-slate-200">
<td className="px-4 py-2" colSpan={4}>
<button className="text-blue-600 hover:text-blue-700 font-medium text-sm flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Position hinzufuegen
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
)
}
function Step6Finanzierungsplan({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Foerderquote</label>
<div className="flex items-center gap-4">
<input
type="range"
min="50"
max="100"
defaultValue="90"
className="flex-1"
/>
<span className="text-lg font-semibold text-slate-900">90%</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-sm text-slate-500">Gesamtkosten</div>
<div className="text-xl font-bold text-slate-900">0,00 EUR</div>
</div>
<div className="p-4 bg-blue-50 rounded-lg">
<div className="text-sm text-blue-600">Foerderbetrag</div>
<div className="text-xl font-bold text-blue-700">0,00 EUR</div>
</div>
</div>
</div>
)
}
function Step7Zeitplan({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Projektbeginn *</label>
<input
type="date"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Projektende *</label>
<input
type="date"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Meilensteine</label>
<p className="text-sm text-slate-500">Definieren Sie wichtige Projektmeilensteine</p>
</div>
</div>
)
}
function Step8Abschluss({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 className="font-semibold text-green-800">Zusammenfassung</h3>
<p className="text-sm text-green-700 mt-1">
Pruefen Sie alle Angaben und laden Sie ggf. zusaetzliche Dokumente hoch.
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Datenschutzkonzept *</label>
<textarea
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="Beschreiben Sie die Massnahmen zum Datenschutz..."
/>
</div>
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg">
<h3 className="font-semibold text-amber-800">Hinweis zur Traegerpruefung</h3>
<p className="text-sm text-amber-700 mt-1">
Der generierte Antrag ist ein antragsfaehiger ENTWURF.
Die finale Pruefung und Einreichung erfolgt durch den Schultraeger.
</p>
</div>
<div className="space-y-3">
<label className="flex items-center gap-3">
<input type="checkbox" className="w-4 h-4 rounded border-slate-300" />
<span className="text-sm text-slate-700">Ich bestaetige, dass alle Angaben nach bestem Wissen gemacht wurden</span>
</label>
<label className="flex items-center gap-3">
<input type="checkbox" className="w-4 h-4 rounded border-slate-300" />
<span className="text-sm text-slate-700">Ich habe verstanden, dass der Antrag vom Schultraeger geprueft werden muss</span>
</label>
</div>
</div>
)
}

View File

@@ -1,368 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
// Types
type FundingProgram = 'DIGITALPAKT_1' | 'DIGITALPAKT_2' | 'LANDESFOERDERUNG' | 'SCHULTRAEGER'
type FederalState = 'NI' | 'NRW' | 'BAY' | 'BW' | 'HE' | 'SN' | 'TH' | 'SA' | 'BB' | 'MV' | 'SH' | 'HH' | 'HB' | 'BE' | 'SL' | 'RP'
interface FormData {
title: string
funding_program: FundingProgram
federal_state: FederalState
preset_id: string
}
const API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8080'
const fundingPrograms = [
{ value: 'DIGITALPAKT_2', label: 'DigitalPakt 2.0', description: 'Foerderung digitaler Bildungsinfrastruktur (2025-2030)' },
{ value: 'DIGITALPAKT_1', label: 'DigitalPakt 1.0 (Restmittel)', description: 'Restmittel aus der ersten Phase' },
{ value: 'LANDESFOERDERUNG', label: 'Landesfoerderung', description: 'Landesspezifische Foerderprogramme' },
{ value: 'SCHULTRAEGER', label: 'Schultraegerfoerderung', description: 'Foerderung durch Schultraeger' },
]
const federalStates = [
{ value: 'NI', label: 'Niedersachsen', flag: 'NI' },
{ value: 'NRW', label: 'Nordrhein-Westfalen', flag: 'NRW' },
{ value: 'BAY', label: 'Bayern', flag: 'BAY' },
{ value: 'BW', label: 'Baden-Wuerttemberg', flag: 'BW' },
{ value: 'HE', label: 'Hessen', flag: 'HE' },
{ value: 'SN', label: 'Sachsen', flag: 'SN' },
{ value: 'TH', label: 'Thueringen', flag: 'TH' },
{ value: 'SA', label: 'Sachsen-Anhalt', flag: 'SA' },
{ value: 'BB', label: 'Brandenburg', flag: 'BB' },
{ value: 'MV', label: 'Mecklenburg-Vorpommern', flag: 'MV' },
{ value: 'SH', label: 'Schleswig-Holstein', flag: 'SH' },
{ value: 'HH', label: 'Hamburg', flag: 'HH' },
{ value: 'HB', label: 'Bremen', flag: 'HB' },
{ value: 'BE', label: 'Berlin', flag: 'BE' },
{ value: 'SL', label: 'Saarland', flag: 'SL' },
{ value: 'RP', label: 'Rheinland-Pfalz', flag: 'RP' },
]
const presets = [
{
id: 'breakpilot_basic',
name: 'BreakPilot Basis',
description: 'Lokale KI-Arbeitsstation fuer eine Schule',
budget: '~18.500 EUR',
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
color: 'blue',
},
{
id: 'breakpilot_cluster',
name: 'BreakPilot Schulverbund',
description: 'Zentrale KI-Infrastruktur fuer mehrere Schulen',
budget: '~68.500 EUR',
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
color: 'purple',
},
{
id: '',
name: 'Individuell',
description: 'Leerer Wizard fuer eigene Projekte',
budget: 'Flexibel',
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
),
color: 'slate',
},
]
export default function NewFoerderantragPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [formData, setFormData] = useState<FormData>({
title: '',
funding_program: 'DIGITALPAKT_2',
federal_state: 'NI',
preset_id: searchParams.get('preset') || '',
})
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Set preset from URL params
useEffect(() => {
const preset = searchParams.get('preset')
if (preset) {
setFormData(prev => ({ ...prev, preset_id: preset }))
// Auto-generate title based on preset
const presetInfo = presets.find(p => p.id === preset)
if (presetInfo && presetInfo.id) {
setFormData(prev => ({
...prev,
preset_id: preset,
title: `${presetInfo.name} - ${new Date().toLocaleDateString('de-DE')}`,
}))
}
}
}, [searchParams])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!formData.title.trim()) {
setError('Bitte geben Sie einen Projekttitel ein')
return
}
setIsSubmitting(true)
try {
// In production, this would call the API
// const response = await fetch(`${API_BASE}/sdk/v1/funding/applications`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(formData),
// })
// const data = await response.json()
// router.push(`/education/foerderantrag/${data.id}`)
// For now, redirect to mock ID
const mockId = 'demo-' + Date.now()
router.push(`/education/foerderantrag/${mockId}`)
} catch (err) {
setError('Fehler beim Erstellen des Antrags')
} finally {
setIsSubmitting(false)
}
}
const getPresetColorClasses = (color: string, isSelected: boolean) => {
const colors: Record<string, { border: string; bg: string; ring: string }> = {
blue: {
border: isSelected ? 'border-blue-500' : 'border-slate-200',
bg: isSelected ? 'bg-blue-50' : 'bg-white',
ring: 'ring-blue-500',
},
purple: {
border: isSelected ? 'border-purple-500' : 'border-slate-200',
bg: isSelected ? 'bg-purple-50' : 'bg-white',
ring: 'ring-purple-500',
},
slate: {
border: isSelected ? 'border-slate-500' : 'border-slate-200',
bg: isSelected ? 'bg-slate-50' : 'bg-white',
ring: 'ring-slate-500',
},
}
return colors[color] || colors.slate
}
return (
<div className="max-w-4xl mx-auto">
{/* Back Link */}
<Link
href="/education/foerderantrag"
className="inline-flex items-center gap-2 text-slate-600 hover:text-slate-900 mb-6"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck zur Uebersicht
</Link>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-900">Neuen Foerderantrag starten</h1>
<p className="mt-2 text-slate-600">
Waehlen Sie das Foerderprogramm und Ihr Bundesland. Der Wizard fuehrt Sie durch alle weiteren Schritte.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Preset Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Schnellstart mit Preset (optional)
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{presets.map((preset) => {
const isSelected = formData.preset_id === preset.id
const colors = getPresetColorClasses(preset.color, isSelected)
return (
<button
key={preset.id || 'custom'}
type="button"
onClick={() => setFormData(prev => ({
...prev,
preset_id: preset.id,
title: preset.id ? `${preset.name} - ${new Date().toLocaleDateString('de-DE')}` : prev.title,
}))}
className={`relative p-4 rounded-xl border-2 text-left transition-all ${colors.border} ${colors.bg} ${isSelected ? 'ring-2 ' + colors.ring : ''}`}
>
{isSelected && (
<div className="absolute top-2 right-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
<div className={`w-12 h-12 rounded-lg bg-${preset.color}-100 text-${preset.color}-600 flex items-center justify-center mb-3`}>
{preset.icon}
</div>
<h3 className="font-semibold text-slate-900">{preset.name}</h3>
<p className="text-sm text-slate-500 mt-1">{preset.description}</p>
<p className="text-sm font-medium text-slate-700 mt-2">{preset.budget}</p>
</button>
)
})}
</div>
</div>
{/* Funding Program */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Foerderprogramm *
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{fundingPrograms.map((program) => (
<label
key={program.value}
className={`relative flex items-start p-4 rounded-xl border-2 cursor-pointer transition-all ${
formData.funding_program === program.value
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<input
type="radio"
name="funding_program"
value={program.value}
checked={formData.funding_program === program.value}
onChange={(e) => setFormData(prev => ({ ...prev, funding_program: e.target.value as FundingProgram }))}
className="sr-only"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{program.label}</span>
{formData.funding_program === program.value && (
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<p className="text-sm text-slate-500 mt-1">{program.description}</p>
</div>
</label>
))}
</div>
</div>
{/* Federal State */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Bundesland *
</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{federalStates.map((state) => (
<button
key={state.value}
type="button"
onClick={() => setFormData(prev => ({ ...prev, federal_state: state.value as FederalState }))}
className={`px-4 py-3 rounded-lg border-2 text-sm font-medium transition-all ${
formData.federal_state === state.value
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-slate-200 bg-white text-slate-700 hover:border-slate-300'
}`}
>
{state.label}
</button>
))}
</div>
<p className="mt-2 text-sm text-slate-500">
{formData.federal_state === 'NI' && 'Niedersachsen ist der Pilot-Standort mit optimaler Unterstuetzung.'}
</p>
</div>
{/* Project Title */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-slate-700 mb-2">
Projekttitel *
</label>
<input
type="text"
id="title"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
placeholder="z.B. Digitale Lernumgebung fuer differenzierten Unterricht"
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
maxLength={200}
/>
<p className="mt-2 text-sm text-slate-500">
Ein aussagekraeftiger Titel fuer Ihr Foerderprojekt (max. 200 Zeichen)
</p>
</div>
{/* Error */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-red-700">
{error}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between pt-4 border-t border-slate-200">
<Link
href="/education/foerderantrag"
className="px-6 py-3 text-slate-600 hover:text-slate-900 font-medium"
>
Abbrechen
</Link>
<button
type="submit"
disabled={isSubmitting}
className="px-8 py-3 bg-blue-600 text-white rounded-xl font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition-colors"
>
{isSubmitting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Wird erstellt...
</>
) : (
<>
Wizard starten
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</>
)}
</button>
</div>
</form>
{/* Help Box */}
<div className="mt-8 bg-amber-50 border border-amber-200 rounded-xl p-6">
<div className="flex gap-4">
<div className="flex-shrink-0">
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-amber-800">KI-Assistent verfuegbar</h3>
<p className="mt-1 text-sm text-amber-700">
Im Wizard steht Ihnen ein KI-Assistent zur Seite, der bei Fragen hilft,
Formulierungen vorschlaegt und Sie durch den Antragsprozess fuehrt.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,365 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
// Types
interface FundingApplication {
id: string
application_number: string
title: string
funding_program: string
status: string
current_step: number
total_steps: number
requested_amount: number
school_profile?: {
name: string
federal_state: string
}
created_at: string
updated_at: string
}
interface Statistics {
total_applications: number
draft_count: number
submitted_count: number
approved_count: number
total_requested: number
total_approved: number
}
const API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8080'
// Status badge colors
const statusColors: Record<string, { bg: string; text: string; label: string }> = {
DRAFT: { bg: 'bg-slate-100', text: 'text-slate-700', label: 'Entwurf' },
IN_PROGRESS: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'In Bearbeitung' },
REVIEW: { bg: 'bg-amber-100', text: 'text-amber-700', label: 'Pruefung' },
SUBMITTED: { bg: 'bg-purple-100', text: 'text-purple-700', label: 'Eingereicht' },
APPROVED: { bg: 'bg-green-100', text: 'text-green-700', label: 'Genehmigt' },
REJECTED: { bg: 'bg-red-100', text: 'text-red-700', label: 'Abgelehnt' },
}
const programLabels: Record<string, string> = {
DIGITALPAKT_1: 'DigitalPakt 1.0',
DIGITALPAKT_2: 'DigitalPakt 2.0',
LANDESFOERDERUNG: 'Landesfoerderung',
SCHULTRAEGER: 'Schultraeger',
}
export default function FoerderantragPage() {
const [applications, setApplications] = useState<FundingApplication[]>([])
const [statistics, setStatistics] = useState<Statistics | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
setLoading(true)
// In production, these would be real API calls
// For now, we use mock data
setApplications([])
setStatistics({
total_applications: 0,
draft_count: 0,
submitted_count: 0,
approved_count: 0,
total_requested: 0,
total_approved: 0,
})
} catch (err) {
setError('Fehler beim Laden der Daten')
} finally {
setLoading(false)
}
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount)
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
return (
<div className="space-y-8">
{/* Page Purpose */}
<PagePurpose
title="Foerderantrag-Wizard"
purpose="Erstellen Sie antragsfaehige Foerderantraege fuer Schulen. Der Wizard fuehrt Sie Schritt fuer Schritt durch den Prozess und generiert alle erforderlichen Dokumente."
audience={['Schulleitung', 'IT-Beauftragte', 'Schultraeger']}
architecture={{
services: ['ai-compliance-sdk (Go)', 'LLM-Service (32B)'],
databases: ['PostgreSQL'],
}}
collapsible={true}
defaultCollapsed={true}
/>
{/* Hero Section */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-blue-600 via-blue-700 to-indigo-800 p-8 text-white">
<div className="absolute inset-0 bg-[url('/grid-pattern.svg')] opacity-10" />
<div className="relative z-10">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold">Foerderantrag-Wizard</h1>
<p className="mt-2 text-blue-100 max-w-2xl">
Erstellen Sie vollstaendige Foerderantraege fuer DigitalPakt 2.0 und Landesfoerderungen.
Der Wizard fuehrt Sie durch alle 8 Schritte und generiert antragsfaehige Dokumente.
</p>
<div className="mt-6 flex gap-4">
<Link
href="/education/foerderantrag/new"
className="inline-flex items-center gap-2 px-6 py-3 bg-white text-blue-700 rounded-xl font-semibold hover:bg-blue-50 transition-colors shadow-lg"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neuen Antrag starten
</Link>
</div>
</div>
<div className="hidden lg:block">
<svg className="w-32 h-32 text-blue-300 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
</div>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-blue-100 flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">{statistics?.total_applications || 0}</div>
<div className="text-sm text-slate-500">Antraege gesamt</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-amber-100 flex items-center justify-center">
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">{statistics?.draft_count || 0}</div>
<div className="text-sm text-slate-500">Entwuerfe</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-purple-100 flex items-center justify-center">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">{statistics?.submitted_count || 0}</div>
<div className="text-sm text-slate-500">Eingereicht</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-green-100 flex items-center justify-center">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">{formatCurrency(statistics?.total_requested || 0)}</div>
<div className="text-sm text-slate-500">Beantragt</div>
</div>
</div>
</div>
</div>
{/* Quick Start Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Link
href="/education/foerderantrag/new?preset=breakpilot_basic"
className="group bg-white rounded-xl border-2 border-slate-200 p-6 hover:border-blue-400 hover:shadow-lg transition-all"
>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h3 className="font-semibold text-lg text-slate-900 group-hover:text-blue-600 transition-colors">
BreakPilot Basis
</h3>
<p className="text-sm text-slate-500 mt-1">
Lokale KI-Arbeitsstation fuer eine Schule. Vorausgefuellte Kostenplanung und Datenschutzkonzept.
</p>
<div className="mt-4 text-sm font-medium text-blue-600">
~18.500 EUR Foerdervolumen
</div>
</Link>
<Link
href="/education/foerderantrag/new?preset=breakpilot_cluster"
className="group bg-white rounded-xl border-2 border-slate-200 p-6 hover:border-blue-400 hover:shadow-lg transition-all"
>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500 to-pink-600 flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="font-semibold text-lg text-slate-900 group-hover:text-purple-600 transition-colors">
BreakPilot Schulverbund
</h3>
<p className="text-sm text-slate-500 mt-1">
Zentrale KI-Infrastruktur fuer mehrere Schulen eines Traegers.
</p>
<div className="mt-4 text-sm font-medium text-purple-600">
~68.500 EUR Foerdervolumen
</div>
</Link>
<Link
href="/education/foerderantrag/new"
className="group bg-white rounded-xl border-2 border-slate-200 p-6 hover:border-slate-400 hover:shadow-lg transition-all"
>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-slate-500 to-slate-700 flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</div>
<h3 className="font-semibold text-lg text-slate-900 group-hover:text-slate-700 transition-colors">
Individueller Antrag
</h3>
<p className="text-sm text-slate-500 mt-1">
Leerer Wizard fuer individuelle Projekte. Volle Flexibilitaet bei der Planung.
</p>
<div className="mt-4 text-sm font-medium text-slate-600">
Beliebiges Foerdervolumen
</div>
</Link>
</div>
{/* Applications List */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h2 className="font-semibold text-lg text-slate-900">Meine Antraege</h2>
<div className="flex items-center gap-2">
<select className="px-3 py-1.5 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Alle Status</option>
<option value="DRAFT">Entwurf</option>
<option value="SUBMITTED">Eingereicht</option>
<option value="APPROVED">Genehmigt</option>
</select>
</div>
</div>
{loading ? (
<div className="p-12 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-slate-500">Lade Antraege...</p>
</div>
) : applications.length === 0 ? (
<div className="p-12 text-center">
<svg className="w-16 h-16 text-slate-300 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="mt-4 text-lg font-medium text-slate-900">Noch keine Antraege</h3>
<p className="mt-2 text-slate-500">
Starten Sie jetzt Ihren ersten Foerderantrag mit dem Wizard.
</p>
<Link
href="/education/foerderantrag/new"
className="mt-6 inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Ersten Antrag erstellen
</Link>
</div>
) : (
<div className="divide-y divide-slate-100">
{applications.map((app) => {
const status = statusColors[app.status] || statusColors.DRAFT
return (
<Link
key={app.id}
href={`/education/foerderantrag/${app.id}`}
className="flex items-center gap-4 p-4 hover:bg-slate-50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<h3 className="font-medium text-slate-900 truncate">{app.title}</h3>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${status.bg} ${status.text}`}>
{status.label}
</span>
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-slate-500">
<span>{app.application_number}</span>
<span>{programLabels[app.funding_program] || app.funding_program}</span>
{app.school_profile?.name && (
<span>{app.school_profile.name}</span>
)}
</div>
</div>
<div className="text-right">
<div className="font-medium text-slate-900">{formatCurrency(app.requested_amount)}</div>
<div className="text-sm text-slate-500">Schritt {app.current_step}/{app.total_steps}</div>
</div>
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
)
})}
</div>
)}
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
<div className="flex gap-4">
<div className="flex-shrink-0">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-blue-800">Wichtiger Hinweis</h3>
<p className="mt-1 text-sm text-blue-700">
Der Wizard erstellt einen <strong>antragsfaehigen Entwurf</strong>. Die finale Pruefung und
Einreichung erfolgt durch den Schultraeger. Alle generierten Dokumente (Antragsschreiben,
Kostenplan, Datenschutzkonzept) koennen als ZIP heruntergeladen werden.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -226,7 +226,7 @@ export default function MiddlewareAdminPage() {
relatedPages={[
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
{ name: 'Mac Mini', href: '/infrastructure/mac-mini', description: 'Server-Monitoring' },
{ name: 'Controls', href: '/compliance/controls', description: 'Security Controls' },
{ name: 'Controls', href: '/sdk/controls', description: 'Security Controls' },
]}
collapsible={true}
defaultCollapsed={true}

View File

@@ -424,7 +424,7 @@ export default function SBOMPage() {
}}
relatedPages={[
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
{ name: 'Controls', href: '/compliance/controls', description: 'Security Controls' },
{ name: 'Controls', href: '/sdk/controls', description: 'Security Controls' },
]}
collapsible={true}
defaultCollapsed={true}

View File

@@ -299,7 +299,7 @@ export default function SecurityDashboardPage() {
relatedPages={[
{ name: 'SBOM', href: '/infrastructure/sbom', description: 'Software Bill of Materials' },
{ name: 'Middleware', href: '/infrastructure/middleware', description: 'API Gateway & Rate Limiting' },
{ name: 'Controls', href: '/compliance/controls', description: 'Security Controls' },
{ name: 'Controls', href: '/sdk/controls', description: 'Security Controls' },
]}
collapsible={true}
defaultCollapsed={true}

View File

@@ -334,7 +334,7 @@ export default function RBACPage() {
databases: ['compliance_tenants', 'compliance_namespaces', 'compliance_roles', 'compliance_llm_policies'],
}}
relatedPages={[
{ name: 'Audit Trail', href: '/compliance/audit-report', description: 'LLM-Operationen protokollieren' },
{ name: 'Audit Trail', href: '/sdk/audit-report', description: 'LLM-Operationen protokollieren' },
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider testen' },
]}
/>

View File

@@ -0,0 +1,670 @@
'use client'
/**
* Website Manager - CMS Dashboard
*
* Visual CMS dashboard for the BreakPilot website (macmini:3000).
* 60/40 split: Section cards with inline editors | Live iframe preview.
* Status bar, content stats, reset, save.
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import type {
WebsiteContent,
HeroContent,
FeatureContent,
FAQItem,
PricingPlan,
} from '@/lib/content-types'
const ADMIN_KEY = 'breakpilot-admin-2024'
// Section metadata for cards
const SECTIONS = [
{ key: 'hero', name: 'Hero Section', icon: '🎯', scrollTo: 'hero' },
{ key: 'features', name: 'Features', icon: '⚡', scrollTo: 'features' },
{ key: 'faq', name: 'FAQ', icon: '❓', scrollTo: 'faq' },
{ key: 'pricing', name: 'Pricing', icon: '💰', scrollTo: 'pricing' },
{ key: 'trust', name: 'Trust Indicators', icon: '🛡️', scrollTo: 'trust' },
{ key: 'testimonial', name: 'Testimonial', icon: '💬', scrollTo: 'trust' },
] as const
type SectionKey = (typeof SECTIONS)[number]['key']
// ─── Helpers ───────────────────────────────────────────────────────────────
function countWords(content: WebsiteContent): number {
const texts: string[] = []
// Hero
texts.push(content.hero.badge, content.hero.title, content.hero.titleHighlight1, content.hero.titleHighlight2, content.hero.subtitle, content.hero.ctaPrimary, content.hero.ctaSecondary, content.hero.ctaHint)
// Features
content.features.forEach(f => { texts.push(f.title, f.description) })
// FAQ
content.faq.forEach(f => { texts.push(f.question, ...f.answer) })
// Pricing
content.pricing.forEach(p => { texts.push(p.name, p.description, p.features.tasks, p.features.taskDescription, ...p.features.included) })
// Trust
texts.push(content.trust.item1.value, content.trust.item1.label, content.trust.item2.value, content.trust.item2.label, content.trust.item3.value, content.trust.item3.label)
// Testimonial
texts.push(content.testimonial.quote, content.testimonial.author, content.testimonial.role)
return texts.filter(Boolean).join(' ').split(/\s+/).filter(Boolean).length
}
function sectionComplete(content: WebsiteContent, section: SectionKey): boolean {
switch (section) {
case 'hero':
return !!(content.hero.title && content.hero.subtitle && content.hero.ctaPrimary)
case 'features':
return content.features.length > 0 && content.features.every(f => f.title && f.description)
case 'faq':
return content.faq.length > 0 && content.faq.every(f => f.question && f.answer.length > 0)
case 'pricing':
return content.pricing.length > 0 && content.pricing.every(p => p.name && p.price > 0)
case 'trust':
return !!(content.trust.item1.value && content.trust.item2.value && content.trust.item3.value)
case 'testimonial':
return !!(content.testimonial.quote && content.testimonial.author)
}
}
function sectionSummary(content: WebsiteContent, section: SectionKey): string {
switch (section) {
case 'hero':
return `"${content.hero.title} ${content.hero.titleHighlight1}"`.slice(0, 50)
case 'features':
return `${content.features.length} Features`
case 'faq':
return `${content.faq.length} Fragen`
case 'pricing':
return `${content.pricing.length} Plaene`
case 'trust':
return `${content.trust.item1.value}, ${content.trust.item2.value}, ${content.trust.item3.value}`
case 'testimonial':
return `"${content.testimonial.quote.slice(0, 40)}..."`
}
}
// ─── Main Component ────────────────────────────────────────────────────────
export default function WebsiteManagerPage() {
const [content, setContent] = useState<WebsiteContent | null>(null)
const [originalContent, setOriginalContent] = useState<WebsiteContent | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [expandedSection, setExpandedSection] = useState<SectionKey | null>(null)
const [websiteStatus, setWebsiteStatus] = useState<{ online: boolean; responseTime: number } | null>(null)
const iframeRef = useRef<HTMLIFrameElement>(null)
// Load content
useEffect(() => {
loadContent()
checkWebsiteStatus()
}, [])
// Auto-dismiss messages
useEffect(() => {
if (message) {
const t = setTimeout(() => setMessage(null), 4000)
return () => clearTimeout(t)
}
}, [message])
async function loadContent() {
try {
const res = await fetch('/api/website/content')
if (res.ok) {
const data = await res.json()
setContent(data)
setOriginalContent(JSON.parse(JSON.stringify(data)))
} else {
setMessage({ type: 'error', text: 'Fehler beim Laden des Contents' })
}
} catch {
setMessage({ type: 'error', text: 'Verbindungsfehler beim Laden' })
} finally {
setLoading(false)
}
}
async function checkWebsiteStatus() {
try {
const res = await fetch('/api/website/status')
if (res.ok) {
const data = await res.json()
setWebsiteStatus(data)
}
} catch {
setWebsiteStatus({ online: false, responseTime: 0 })
}
}
async function saveChanges() {
if (!content) return
setSaving(true)
setMessage(null)
try {
const res = await fetch('/api/website/content', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-admin-key': ADMIN_KEY },
body: JSON.stringify(content),
})
if (res.ok) {
setMessage({ type: 'success', text: 'Erfolgreich gespeichert!' })
setOriginalContent(JSON.parse(JSON.stringify(content)))
// Reload iframe to reflect changes
if (iframeRef.current) {
iframeRef.current.src = iframeRef.current.src
}
} else {
const err = await res.json()
setMessage({ type: 'error', text: err.error || 'Fehler beim Speichern' })
}
} catch {
setMessage({ type: 'error', text: 'Verbindungsfehler beim Speichern' })
} finally {
setSaving(false)
}
}
function resetContent() {
if (originalContent) {
setContent(JSON.parse(JSON.stringify(originalContent)))
setMessage({ type: 'success', text: 'Zurueckgesetzt auf letzten gespeicherten Stand' })
}
}
// Scroll iframe to section
const scrollPreview = useCallback((scrollTo: string) => {
if (!iframeRef.current?.contentWindow) return
try {
iframeRef.current.contentWindow.postMessage(
{ type: 'scrollTo', section: scrollTo },
'*'
)
} catch {
// cross-origin fallback
}
}, [])
function toggleSection(key: SectionKey) {
const newExpanded = expandedSection === key ? null : key
setExpandedSection(newExpanded)
if (newExpanded) {
const section = SECTIONS.find(s => s.key === newExpanded)
if (section) scrollPreview(section.scrollTo)
}
}
// ─── Render ────────────────────────────────────────────────────────────────
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="flex items-center gap-3 text-slate-500">
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Lade Website-Content...
</div>
</div>
)
}
if (!content) {
return (
<div className="flex items-center justify-center py-20">
<div className="text-red-600">Content konnte nicht geladen werden.</div>
</div>
)
}
const wordCount = countWords(content)
const completeSections = SECTIONS.filter(s => sectionComplete(content, s.key)).length
const completionPct = Math.round((completeSections / SECTIONS.length) * 100)
const hasChanges = JSON.stringify(content) !== JSON.stringify(originalContent)
return (
<div className="space-y-4">
{/* ── Status Bar ───────────────────────────────────────────────────── */}
<div className="bg-white rounded-xl border border-slate-200 px-5 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Website status */}
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${websiteStatus?.online ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm text-slate-700">
Website {websiteStatus?.online ? 'online' : 'offline'}
{websiteStatus?.online && websiteStatus.responseTime > 0 && (
<span className="text-slate-400 ml-1">({websiteStatus.responseTime}ms)</span>
)}
</span>
</div>
{/* Link */}
<a
href="https://macmini:3000"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-sky-600 hover:text-sky-700 flex items-center gap-1"
>
Zur Website
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<div className="flex items-center gap-3">
{message && (
<span className={`px-3 py-1 rounded-lg text-sm font-medium ${
message.type === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{message.text}
</span>
)}
<button
onClick={resetContent}
disabled={!hasChanges}
className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Reset
</button>
<button
onClick={saveChanges}
disabled={saving || !hasChanges}
className="px-5 py-2 text-sm font-medium text-white bg-sky-600 rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
{/* ── Stats Bar ────────────────────────────────────────────────────── */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Sektionen', value: `${SECTIONS.length}`, icon: '📄' },
{ label: 'Woerter', value: wordCount.toLocaleString('de-DE'), icon: '📝' },
{ label: 'Vollstaendig', value: `${completionPct}%`, icon: completionPct === 100 ? '✅' : '🔧' },
{ label: 'Aenderungen', value: hasChanges ? 'Ungespeichert' : 'Aktuell', icon: hasChanges ? '🟡' : '🟢' },
].map((stat) => (
<div key={stat.label} className="bg-white rounded-xl border border-slate-200 px-4 py-3 flex items-center gap-3">
<span className="text-xl">{stat.icon}</span>
<div>
<div className="text-sm font-semibold text-slate-900">{stat.value}</div>
<div className="text-xs text-slate-500">{stat.label}</div>
</div>
</div>
))}
</div>
{/* ── Main Layout: 60/40 ───────────────────────────────────────────── */}
<div className="grid grid-cols-5 gap-4" style={{ height: 'calc(100vh - 300px)' }}>
{/* ── Left: Section Cards (3/5 = 60%) ──────────────────────────── */}
<div className="col-span-3 overflow-y-auto pr-1 space-y-3">
{SECTIONS.map((section) => {
const isExpanded = expandedSection === section.key
const isComplete = sectionComplete(content, section.key)
return (
<div
key={section.key}
className={`bg-white rounded-xl border transition-all ${
isExpanded ? 'border-sky-300 shadow-md' : 'border-slate-200 hover:border-slate-300'
}`}
>
{/* Card Header */}
<button
onClick={() => toggleSection(section.key)}
className="w-full px-5 py-4 flex items-center justify-between text-left"
>
<div className="flex items-center gap-3">
<span className="text-xl">{section.icon}</span>
<div>
<div className="font-medium text-slate-900">{section.name}</div>
<div className="text-xs text-slate-500 mt-0.5">{sectionSummary(content, section.key)}</div>
</div>
</div>
<div className="flex items-center gap-3">
{isComplete ? (
<span className="w-6 h-6 rounded-full bg-green-100 text-green-600 flex items-center justify-center text-xs">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
</span>
) : (
<span className="w-6 h-6 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center text-xs">!</span>
)}
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{/* Inline Editor */}
{isExpanded && (
<div className="px-5 pb-5 border-t border-slate-100 pt-4">
{section.key === 'hero' && <HeroEditor content={content} setContent={setContent} />}
{section.key === 'features' && <FeaturesEditor content={content} setContent={setContent} />}
{section.key === 'faq' && <FAQEditor content={content} setContent={setContent} />}
{section.key === 'pricing' && <PricingEditor content={content} setContent={setContent} />}
{section.key === 'trust' && <TrustEditor content={content} setContent={setContent} />}
{section.key === 'testimonial' && <TestimonialEditor content={content} setContent={setContent} />}
</div>
)}
</div>
)
})}
</div>
{/* ── Right: Live Preview (2/5 = 40%) ──────────────────────────── */}
<div className="col-span-2 bg-white rounded-xl border border-slate-200 overflow-hidden flex flex-col">
{/* Preview Header */}
<div className="bg-slate-50 border-b border-slate-200 px-4 py-2.5 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
<div className="w-2.5 h-2.5 rounded-full bg-yellow-400" />
<div className="w-2.5 h-2.5 rounded-full bg-green-400" />
</div>
<span className="text-xs text-slate-500 ml-2">macmini:3000</span>
</div>
<button
onClick={() => { if (iframeRef.current) iframeRef.current.src = iframeRef.current.src }}
className="p-1 text-slate-400 hover:text-slate-600 rounded transition-colors"
title="Preview neu laden"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
{/* iframe */}
<div className="flex-1 relative bg-slate-100">
<iframe
ref={iframeRef}
src="https://macmini:3000/?preview=true"
className="absolute inset-0 w-full h-full border-0"
style={{
width: '166.67%',
height: '166.67%',
transform: 'scale(0.6)',
transformOrigin: 'top left',
}}
title="Website Preview"
sandbox="allow-same-origin allow-scripts"
/>
</div>
</div>
</div>
</div>
)
}
// ─── Section Editors ─────────────────────────────────────────────────────────
interface EditorProps {
content: WebsiteContent
setContent: React.Dispatch<React.SetStateAction<WebsiteContent | null>>
}
const inputCls = 'w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors'
const labelCls = 'block text-xs font-medium text-slate-600 mb-1'
// ─── Hero Editor ─────────────────────────────────────────────────────────────
function HeroEditor({ content, setContent }: EditorProps) {
function update(field: keyof HeroContent, value: string) {
setContent(c => c ? { ...c, hero: { ...c.hero, [field]: value } } : c)
}
return (
<div className="grid gap-3">
<div>
<label className={labelCls}>Badge</label>
<input className={inputCls} value={content.hero.badge} onChange={e => update('badge', e.target.value)} />
</div>
<div>
<label className={labelCls}>Titel</label>
<input className={inputCls} value={content.hero.title} onChange={e => update('title', e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Highlight 1</label>
<input className={inputCls} value={content.hero.titleHighlight1} onChange={e => update('titleHighlight1', e.target.value)} />
</div>
<div>
<label className={labelCls}>Highlight 2</label>
<input className={inputCls} value={content.hero.titleHighlight2} onChange={e => update('titleHighlight2', e.target.value)} />
</div>
</div>
<div>
<label className={labelCls}>Untertitel</label>
<textarea className={inputCls} rows={2} value={content.hero.subtitle} onChange={e => update('subtitle', e.target.value)} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelCls}>CTA Primaer</label>
<input className={inputCls} value={content.hero.ctaPrimary} onChange={e => update('ctaPrimary', e.target.value)} />
</div>
<div>
<label className={labelCls}>CTA Sekundaer</label>
<input className={inputCls} value={content.hero.ctaSecondary} onChange={e => update('ctaSecondary', e.target.value)} />
</div>
<div>
<label className={labelCls}>CTA Hinweis</label>
<input className={inputCls} value={content.hero.ctaHint} onChange={e => update('ctaHint', e.target.value)} />
</div>
</div>
</div>
)
}
// ─── Features Editor ─────────────────────────────────────────────────────────
function FeaturesEditor({ content, setContent }: EditorProps) {
function update(index: number, field: keyof FeatureContent, value: string) {
setContent(c => {
if (!c) return c
const features = [...c.features]
features[index] = { ...features[index], [field]: value }
return { ...c, features }
})
}
return (
<div className="space-y-3">
{content.features.map((feature, i) => (
<div key={feature.id} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="grid grid-cols-6 gap-2">
<div>
<label className={labelCls}>Icon</label>
<input className={`${inputCls} text-center text-lg`} value={feature.icon} onChange={e => update(i, 'icon', e.target.value)} />
</div>
<div className="col-span-5">
<label className={labelCls}>Titel</label>
<input className={inputCls} value={feature.title} onChange={e => update(i, 'title', e.target.value)} />
</div>
</div>
<div>
<label className={labelCls}>Beschreibung</label>
<textarea className={inputCls} rows={2} value={feature.description} onChange={e => update(i, 'description', e.target.value)} />
</div>
</div>
))}
</div>
)
}
// ─── FAQ Editor ──────────────────────────────────────────────────────────────
function FAQEditor({ content, setContent }: EditorProps) {
function updateItem(index: number, field: 'question' | 'answer', value: string) {
setContent(c => {
if (!c) return c
const faq = [...c.faq]
if (field === 'answer') {
faq[index] = { ...faq[index], answer: value.split('\n') }
} else {
faq[index] = { ...faq[index], question: value }
}
return { ...c, faq }
})
}
function addItem() {
setContent(c => c ? { ...c, faq: [...c.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }] } : c)
}
function removeItem(index: number) {
setContent(c => c ? { ...c, faq: c.faq.filter((_, i) => i !== index) } : c)
}
return (
<div className="space-y-3">
{content.faq.map((item, i) => (
<div key={i} className="bg-slate-50 rounded-lg p-3 space-y-2 relative group">
<button
onClick={() => removeItem(i)}
className="absolute top-2 right-2 p-1 text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
title="Entfernen"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div>
<label className={labelCls}>Frage {i + 1}</label>
<input className={inputCls} value={item.question} onChange={e => updateItem(i, 'question', e.target.value)} />
</div>
<div>
<label className={labelCls}>Antwort</label>
<textarea className={`${inputCls} font-mono`} rows={3} value={item.answer.join('\n')} onChange={e => updateItem(i, 'answer', e.target.value)} />
</div>
</div>
))}
<button onClick={addItem} className="w-full py-2 border-2 border-dashed border-slate-300 rounded-lg text-sm text-slate-500 hover:border-sky-400 hover:text-sky-600 transition-colors">
+ Frage hinzufuegen
</button>
</div>
)
}
// ─── Pricing Editor ──────────────────────────────────────────────────────────
function PricingEditor({ content, setContent }: EditorProps) {
function update(index: number, field: string, value: string | number | boolean) {
setContent(c => {
if (!c) return c
const pricing = [...c.pricing]
if (field === 'price') {
pricing[index] = { ...pricing[index], price: Number(value) }
} else if (field === 'popular') {
pricing[index] = { ...pricing[index], popular: Boolean(value) }
} else if (field.startsWith('features.')) {
const sub = field.replace('features.', '')
if (sub === 'included' && typeof value === 'string') {
pricing[index] = { ...pricing[index], features: { ...pricing[index].features, included: value.split('\n') } }
} else {
pricing[index] = { ...pricing[index], features: { ...pricing[index].features, [sub]: value } }
}
} else {
pricing[index] = { ...pricing[index], [field]: value }
}
return { ...c, pricing }
})
}
return (
<div className="space-y-4">
{content.pricing.map((plan, i) => (
<div key={plan.id} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-slate-800">{plan.name}</span>
{plan.popular && <span className="text-xs bg-sky-100 text-sky-700 px-1.5 py-0.5 rounded">Beliebt</span>}
</div>
<div className="grid grid-cols-4 gap-2">
<div>
<label className={labelCls}>Name</label>
<input className={inputCls} value={plan.name} onChange={e => update(i, 'name', e.target.value)} />
</div>
<div>
<label className={labelCls}>Preis (EUR)</label>
<input className={inputCls} type="number" step="0.01" value={plan.price} onChange={e => update(i, 'price', e.target.value)} />
</div>
<div>
<label className={labelCls}>Intervall</label>
<input className={inputCls} value={plan.interval} onChange={e => update(i, 'interval', e.target.value)} />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={plan.popular || false} onChange={e => update(i, 'popular', e.target.checked)} className="w-4 h-4 text-sky-600 rounded" />
<span className="text-xs text-slate-600">Beliebt</span>
</label>
</div>
</div>
<div>
<label className={labelCls}>Beschreibung</label>
<input className={inputCls} value={plan.description} onChange={e => update(i, 'description', e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelCls}>Aufgaben</label>
<input className={inputCls} value={plan.features.tasks} onChange={e => update(i, 'features.tasks', e.target.value)} />
</div>
<div>
<label className={labelCls}>Aufgaben-Beschreibung</label>
<input className={inputCls} value={plan.features.taskDescription} onChange={e => update(i, 'features.taskDescription', e.target.value)} />
</div>
</div>
<div>
<label className={labelCls}>Features (eine pro Zeile)</label>
<textarea className={`${inputCls} font-mono`} rows={3} value={plan.features.included.join('\n')} onChange={e => update(i, 'features.included', e.target.value)} />
</div>
</div>
))}
</div>
)
}
// ─── Trust Editor ────────────────────────────────────────────────────────────
function TrustEditor({ content, setContent }: EditorProps) {
function update(key: 'item1' | 'item2' | 'item3', field: 'value' | 'label', val: string) {
setContent(c => c ? { ...c, trust: { ...c.trust, [key]: { ...c.trust[key], [field]: val } } } : c)
}
return (
<div className="grid grid-cols-3 gap-3">
{(['item1', 'item2', 'item3'] as const).map((key, i) => (
<div key={key} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div>
<label className={labelCls}>Wert {i + 1}</label>
<input className={inputCls} value={content.trust[key].value} onChange={e => update(key, 'value', e.target.value)} />
</div>
<div>
<label className={labelCls}>Label {i + 1}</label>
<input className={inputCls} value={content.trust[key].label} onChange={e => update(key, 'label', e.target.value)} />
</div>
</div>
))}
</div>
)
}
// ─── Testimonial Editor ──────────────────────────────────────────────────────
function TestimonialEditor({ content, setContent }: EditorProps) {
function update(field: 'quote' | 'author' | 'role', value: string) {
setContent(c => c ? { ...c, testimonial: { ...c.testimonial, [field]: value } } : c)
}
return (
<div className="space-y-3">
<div>
<label className={labelCls}>Zitat</label>
<textarea className={inputCls} rows={3} value={content.testimonial.quote} onChange={e => update('quote', e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Autor</label>
<input className={inputCls} value={content.testimonial.author} onChange={e => update('author', e.target.value)} />
</div>
<div>
<label className={labelCls}>Rolle</label>
<input className={inputCls} value={content.testimonial.role} onChange={e => update('role', e.target.value)} />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
'use client'
import { getCategoryById } from '@/lib/navigation'
import { ModuleCard } from '@/components/common/ModuleCard'
import { PagePurpose } from '@/components/common/PagePurpose'
export default function WebsitePage() {
const category = getCategoryById('website')
if (!category) {
return <div>Kategorie nicht gefunden</div>
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title={category.name}
purpose="Website Content & Management. Verwalten Sie Inhalte, Uebersetzungen und das CMS."
audience={['Content Manager', 'Entwickler']}
architecture={{
services: ['website (Next.js)'],
databases: [],
}}
collapsible={true}
defaultCollapsed={false}
/>
{/* Modules Grid */}
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{category.modules.map((module) => (
<ModuleCard key={module.id} module={module} category={category} />
))}
</div>
{/* Info Section */}
<div className="mt-8 bg-sky-50 border border-sky-200 rounded-xl p-6">
<h3 className="font-semibold text-sky-800 flex items-center gap-2">
<span>🌐</span>
Website CMS
</h3>
<p className="text-sm text-sky-700 mt-2">
Die BreakPilot Website wird ueber ein visuelles CMS verwaltet.
Inhalte koennen direkt bearbeitet und in mehrere Sprachen uebersetzt werden.
Aenderungen werden nach dem Speichern sofort auf der Website sichtbar.
</p>
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
/**
* Admin Panel for Website Content
* Uebersetzungen - Website Content Editor
*
* Allows editing all website texts:
* - Hero Section
@@ -29,7 +29,7 @@ const SECTION_MAP: Record<string, { selector: string; scrollTo: string }> = {
other: { selector: '#trust', scrollTo: 'trust' },
}
export default function ContentPage() {
export default function UebersetzungenPage() {
const [content, setContent] = useState<WebsiteContent | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -66,7 +66,7 @@ export default function ContentPage() {
async function loadContent() {
try {
const res = await fetch('/api/development/content')
const res = await fetch('/api/website/content')
if (res.ok) {
const data = await res.json()
setContent(data)
@@ -87,7 +87,7 @@ export default function ContentPage() {
setMessage(null)
try {
const res = await fetch('/api/development/content', {
const res = await fetch('/api/website/content', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -208,7 +208,7 @@ export default function ContentPage() {
{/* Toolbar */}
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-lg font-semibold text-slate-900">Website Content</h1>
<h1 className="text-lg font-semibold text-slate-900">Uebersetzungen</h1>
{/* Preview Toggle */}
<button
onClick={() => setShowPreview(!showPreview)}

View File

@@ -0,0 +1,517 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import {
Course,
Lesson,
Enrollment,
QuizQuestion,
COURSE_CATEGORY_INFO,
ENROLLMENT_STATUS_INFO,
isEnrollmentOverdue,
getDaysUntilDeadline
} from '@/lib/sdk/academy/types'
import {
fetchCourse,
fetchEnrollments,
deleteCourse,
submitQuiz,
generateVideos,
getVideoStatus
} from '@/lib/sdk/academy/api'
type TabId = 'overview' | 'lessons' | 'enrollments' | 'videos'
export default function CourseDetailPage() {
const params = useParams()
const router = useRouter()
const courseId = params.id as string
const [course, setCourse] = useState<Course | null>(null)
const [enrollments, setEnrollments] = useState<Enrollment[]>([])
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [isLoading, setIsLoading] = useState(true)
const [selectedLesson, setSelectedLesson] = useState<Lesson | null>(null)
const [quizAnswers, setQuizAnswers] = useState<Record<string, number>>({})
const [quizResult, setQuizResult] = useState<any>(null)
const [isSubmittingQuiz, setIsSubmittingQuiz] = useState(false)
const [videoStatus, setVideoStatus] = useState<any>(null)
const [isGeneratingVideos, setIsGeneratingVideos] = useState(false)
useEffect(() => {
const loadData = async () => {
setIsLoading(true)
try {
const [courseData, enrollmentData] = await Promise.all([
fetchCourse(courseId).catch(() => null),
fetchEnrollments(courseId).catch(() => [])
])
setCourse(courseData)
setEnrollments(Array.isArray(enrollmentData) ? enrollmentData : [])
if (courseData && courseData.lessons && courseData.lessons.length > 0) {
setSelectedLesson(courseData.lessons[0])
}
} catch (error) {
console.error('Failed to load course:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [courseId])
const handleDeleteCourse = async () => {
if (!confirm('Sind Sie sicher, dass Sie diesen Kurs loeschen moechten? Diese Aktion kann nicht rueckgaengig gemacht werden.')) return
try {
await deleteCourse(courseId)
router.push('/sdk/academy')
} catch (error) {
console.error('Failed to delete course:', error)
}
}
const handleSubmitQuiz = async () => {
if (!selectedLesson) return
const questions = selectedLesson.quizQuestions || []
const answers = questions.map((q: QuizQuestion) => quizAnswers[q.id] ?? -1)
setIsSubmittingQuiz(true)
try {
const result = await submitQuiz(selectedLesson.id, { answers })
setQuizResult(result)
} catch (error: any) {
console.error('Quiz submission failed:', error)
setQuizResult({ error: error.message || 'Fehler bei der Auswertung' })
} finally {
setIsSubmittingQuiz(false)
}
}
const handleGenerateVideos = async () => {
setIsGeneratingVideos(true)
try {
const status = await generateVideos(courseId)
setVideoStatus(status)
} catch (error) {
console.error('Video generation failed:', error)
} finally {
setIsGeneratingVideos(false)
}
}
const handleCheckVideoStatus = async () => {
try {
const status = await getVideoStatus(courseId)
setVideoStatus(status)
} catch (error) {
console.error('Failed to check video status:', error)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
)
}
if (!course) {
return (
<div className="text-center py-20">
<h2 className="text-xl font-semibold text-gray-900">Kurs nicht gefunden</h2>
<Link href="/sdk/academy" className="mt-4 inline-block text-purple-600 hover:underline">
Zurueck zur Uebersicht
</Link>
</div>
)
}
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
const sortedLessons = [...(course.lessons || [])].sort((a, b) => a.order - b.order)
const completedEnrollments = enrollments.filter(e => e.status === 'completed').length
const overdueEnrollments = enrollments.filter(e => isEnrollmentOverdue(e)).length
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<Link
href="/sdk/academy"
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Link>
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
{categoryInfo.label}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${
course.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
}`}>
{course.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">{course.title}</h1>
<p className="text-sm text-gray-500 mt-1">{course.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDeleteCourse}
className="px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
Loeschen
</button>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Lektionen</div>
<div className="text-2xl font-bold text-gray-900">{sortedLessons.length}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Dauer</div>
<div className="text-2xl font-bold text-gray-900">{course.durationMinutes} Min.</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Teilnehmer</div>
<div className="text-2xl font-bold text-blue-600">{enrollments.length}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Abgeschlossen</div>
<div className="text-2xl font-bold text-green-600">{completedEnrollments}</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px">
{(['overview', 'lessons', 'enrollments', 'videos'] as TabId[]).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{{ overview: 'Uebersicht', lessons: 'Lektionen', enrollments: 'Einschreibungen', videos: 'Videos' }[tab]}
</button>
))}
</nav>
</div>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Kurs-Details</h3>
<dl className="grid grid-cols-2 gap-4 text-sm">
<div><dt className="text-gray-500">Bestehensgrenze</dt><dd className="font-medium text-gray-900">{course.passingScore}%</dd></div>
<div><dt className="text-gray-500">Pflicht fuer</dt><dd className="font-medium text-gray-900">{course.requiredForRoles?.join(', ') || 'Alle'}</dd></div>
<div><dt className="text-gray-500">Erstellt am</dt><dd className="font-medium text-gray-900">{new Date(course.createdAt).toLocaleDateString('de-DE')}</dd></div>
<div><dt className="text-gray-500">Aktualisiert am</dt><dd className="font-medium text-gray-900">{new Date(course.updatedAt).toLocaleDateString('de-DE')}</dd></div>
</dl>
</div>
{/* Lesson List Preview */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Lektionen ({sortedLessons.length})</h3>
<div className="space-y-2">
{sortedLessons.map((lesson, i) => (
<div key={lesson.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50">
<div className="w-8 h-8 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center text-sm font-medium">
{i + 1}
</div>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">{lesson.title}</div>
<div className="text-xs text-gray-500">{lesson.durationMinutes} Min. | {lesson.type === 'video' ? 'Video' : lesson.type === 'quiz' ? 'Quiz' : 'Text'}</div>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${
lesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
lesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-600'
}`}>
{lesson.type === 'quiz' ? 'Quiz' : lesson.type === 'video' ? 'Video' : 'Text'}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Lessons Tab - with content viewer and quiz player */}
{activeTab === 'lessons' && (
<div className="grid grid-cols-3 gap-6">
{/* Lesson Navigation */}
<div className="col-span-1 bg-white rounded-xl border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Lektionen</h3>
<div className="space-y-1">
{sortedLessons.map((lesson, i) => (
<button
key={lesson.id}
onClick={() => { setSelectedLesson(lesson); setQuizResult(null); setQuizAnswers({}) }}
className={`w-full text-left p-3 rounded-lg text-sm transition-colors ${
selectedLesson?.id === lesson.id
? 'bg-purple-50 text-purple-700 border border-purple-200'
: 'hover:bg-gray-50 text-gray-700'
}`}
>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400 w-4">{i + 1}.</span>
<span className="truncate">{lesson.title}</span>
</div>
</button>
))}
</div>
</div>
{/* Lesson Content */}
<div className="col-span-2 bg-white rounded-xl border border-gray-200 p-6">
{selectedLesson ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">{selectedLesson.title}</h2>
<span className={`px-3 py-1 text-xs rounded-full ${
selectedLesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
selectedLesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-600'
}`}>
{selectedLesson.type === 'quiz' ? 'Quiz' : selectedLesson.type === 'video' ? 'Video' : 'Text'}
</span>
</div>
{/* Video Player */}
{selectedLesson.type === 'video' && selectedLesson.videoUrl && (
<div className="aspect-video bg-gray-900 rounded-xl overflow-hidden">
<video
src={selectedLesson.videoUrl}
controls
className="w-full h-full"
/>
</div>
)}
{/* Text Content */}
{(selectedLesson.type === 'text' || selectedLesson.type === 'video') && selectedLesson.contentMarkdown && (
<div className="prose prose-sm max-w-none">
<div className="whitespace-pre-wrap text-gray-700 leading-relaxed">
{selectedLesson.contentMarkdown.split('\n').map((line, i) => {
if (line.startsWith('# ')) return <h1 key={i} className="text-2xl font-bold text-gray-900 mt-6 mb-3">{line.slice(2)}</h1>
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-semibold text-gray-900 mt-5 mb-2">{line.slice(3)}</h2>
if (line.startsWith('### ')) return <h3 key={i} className="text-lg font-medium text-gray-900 mt-4 mb-2">{line.slice(4)}</h3>
if (line.startsWith('- **')) {
const parts = line.slice(2).split('**')
return <li key={i} className="ml-4 list-disc"><strong>{parts[1]}</strong>{parts[2] || ''}</li>
}
if (line.startsWith('- ')) return <li key={i} className="ml-4 list-disc">{line.slice(2)}</li>
if (line.trim() === '') return <br key={i} />
return <p key={i} className="mb-2">{line}</p>
})}
</div>
</div>
)}
{/* Quiz Player */}
{selectedLesson.type === 'quiz' && selectedLesson.quizQuestions && (
<div className="space-y-6">
{selectedLesson.quizQuestions.map((q: QuizQuestion, qi: number) => (
<div key={q.id} className="bg-gray-50 rounded-xl p-5">
<h4 className="font-medium text-gray-900 mb-3">Frage {qi + 1}: {q.question}</h4>
<div className="space-y-2">
{q.options.map((option: string, oi: number) => {
const isSelected = quizAnswers[q.id] === oi
const showResult = quizResult && !quizResult.error
const isCorrect = showResult && quizResult.results?.[qi]?.correct
const wasSelected = showResult && isSelected
let bgClass = 'bg-white border-gray-200 hover:border-purple-300'
if (isSelected && !showResult) bgClass = 'bg-purple-50 border-purple-500'
if (showResult && oi === q.correctOptionIndex) bgClass = 'bg-green-50 border-green-500'
if (showResult && wasSelected && !isCorrect) bgClass = 'bg-red-50 border-red-500'
return (
<button
key={oi}
onClick={() => !quizResult && setQuizAnswers({ ...quizAnswers, [q.id]: oi })}
disabled={!!quizResult}
className={`w-full text-left p-3 rounded-lg border-2 transition-all ${bgClass}`}
>
<span className="text-sm text-gray-700">{String.fromCharCode(65 + oi)}) {option}</span>
</button>
)
})}
</div>
{quizResult && !quizResult.error && quizResult.results?.[qi] && (
<div className={`mt-3 p-3 rounded-lg text-sm ${
quizResult.results[qi].correct ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{quizResult.results[qi].correct ? 'Richtig!' : 'Falsch.'} {q.explanation}
</div>
)}
</div>
))}
{/* Quiz Submit / Result */}
{!quizResult ? (
<button
onClick={handleSubmitQuiz}
disabled={isSubmittingQuiz || Object.keys(quizAnswers).length < (selectedLesson.quizQuestions?.length || 0)}
className="w-full py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50 font-medium"
>
{isSubmittingQuiz ? 'Wird ausgewertet...' : 'Quiz auswerten'}
</button>
) : quizResult.error ? (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">{quizResult.error}</div>
) : (
<div className={`rounded-xl p-6 text-center ${quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<div className={`text-3xl font-bold ${quizResult.passed ? 'text-green-600' : 'text-red-600'}`}>
{quizResult.score}%
</div>
<div className={`text-sm mt-1 ${quizResult.passed ? 'text-green-700' : 'text-red-700'}`}>
{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'} {quizResult.correctAnswers}/{quizResult.totalQuestions} richtig
</div>
<button
onClick={() => { setQuizResult(null); setQuizAnswers({}) }}
className="mt-4 px-4 py-2 text-sm bg-white rounded-lg border hover:bg-gray-50"
>
Quiz wiederholen
</button>
</div>
)}
</div>
)}
</div>
) : (
<p className="text-gray-500 text-center py-10">Waehlen Sie eine Lektion aus.</p>
)}
</div>
</div>
)}
{/* Enrollments Tab */}
{activeTab === 'enrollments' && (
<div className="space-y-4">
{overdueEnrollments > 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
{overdueEnrollments} ueberfaellige Einschreibung(en)
</div>
)}
{enrollments.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<p className="text-gray-500">Noch keine Einschreibungen fuer diesen Kurs.</p>
</div>
) : (
enrollments.map(enrollment => {
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
const overdue = isEnrollmentOverdue(enrollment)
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
return (
<div key={enrollment.id} className={`bg-white rounded-xl border-2 p-5 ${overdue ? 'border-red-200' : 'border-gray-200'}`}>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 text-xs rounded-full ${statusInfo?.bgColor} ${statusInfo?.color}`}>
{statusInfo?.label}
</span>
{overdue && <span className="text-xs text-red-600">Ueberfaellig</span>}
</div>
<div className="font-medium text-gray-900">{enrollment.userName}</div>
<div className="text-sm text-gray-500">{enrollment.userEmail}</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-gray-900">{enrollment.progress}%</div>
<div className="text-xs text-gray-500">
{enrollment.status === 'completed' ? 'Abgeschlossen' : `${daysUntil > 0 ? daysUntil + ' Tage verbleibend' : Math.abs(daysUntil) + ' Tage ueberfaellig'}`}
</div>
</div>
</div>
<div className="mt-3 w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${enrollment.progress === 100 ? 'bg-green-500' : overdue ? 'bg-red-500' : 'bg-purple-500'}`}
style={{ width: `${enrollment.progress}%` }}
/>
</div>
</div>
)
})
)}
</div>
)}
{/* Videos Tab */}
{activeTab === 'videos' && (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Video-Generierung</h3>
<div className="flex gap-2">
<button
onClick={handleCheckVideoStatus}
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50"
>
Status pruefen
</button>
<button
onClick={handleGenerateVideos}
disabled={isGeneratingVideos}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{isGeneratingVideos ? 'Wird gestartet...' : 'Videos generieren'}
</button>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4 text-sm text-blue-700">
Videos werden mit ElevenLabs (Stimme) und HeyGen (Avatar) generiert.
Konfigurieren Sie die API-Keys in den Umgebungsvariablen.
</div>
{videoStatus && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Gesamtstatus:</span>
<span className={`px-2 py-1 text-xs rounded-full ${
videoStatus.status === 'completed' ? 'bg-green-100 text-green-700' :
videoStatus.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{videoStatus.status}
</span>
</div>
{videoStatus.lessons?.map((ls: any) => (
<div key={ls.lessonId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">Lektion {ls.lessonId.slice(-4)}</span>
<span className={`text-xs ${ls.status === 'completed' ? 'text-green-600' : 'text-gray-500'}`}>
{ls.status}
</span>
</div>
))}
</div>
)}
{!videoStatus && (
<p className="text-sm text-gray-500 text-center py-8">
Klicken Sie auf "Videos generieren" um den Prozess zu starten.
</p>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,385 @@
'use client'
import React, { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useSDK } from '@/lib/sdk'
import {
CourseCategory,
COURSE_CATEGORY_INFO,
CreateCourseRequest,
GenerateCourseRequest
} from '@/lib/sdk/academy/types'
import { createCourse, generateCourse } from '@/lib/sdk/academy/api'
type CreationMode = 'manual' | 'ai'
export default function NewCoursePage() {
const router = useRouter()
const [mode, setMode] = useState<CreationMode>('ai')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Manual form state
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState<CourseCategory>('dsgvo_basics')
const [duration, setDuration] = useState(60)
const [passingScore, setPassingScore] = useState(70)
// AI generation state
const [topic, setTopic] = useState('')
const [targetGroup, setTargetGroup] = useState('Alle Mitarbeiter')
const [useRag, setUseRag] = useState(true)
const handleManualCreate = async () => {
if (!title.trim()) {
setError('Bitte geben Sie einen Kurstitel ein.')
return
}
setIsLoading(true)
setError(null)
try {
const tenantId = typeof window !== 'undefined'
? localStorage.getItem('bp_tenant_id') || 'default-tenant'
: 'default-tenant'
const result = await createCourse({
tenantId,
title: title.trim(),
description: description.trim(),
category,
durationMinutes: duration,
passingScore,
requiredForRoles: ['all']
} as any)
// Navigate to the new course
if (result && (result as any).id) {
router.push(`/sdk/academy/${(result as any).id}`)
} else {
router.push('/sdk/academy')
}
} catch (err: any) {
setError(err.message || 'Fehler beim Erstellen des Kurses')
} finally {
setIsLoading(false)
}
}
const handleAIGenerate = async () => {
if (!topic.trim()) {
setError('Bitte geben Sie ein Thema fuer den Kurs ein.')
return
}
setIsLoading(true)
setError(null)
try {
const tenantId = typeof window !== 'undefined'
? localStorage.getItem('bp_tenant_id') || 'default-tenant'
: 'default-tenant'
const result = await generateCourse({
tenantId,
topic: topic.trim(),
category,
targetGroup: targetGroup.trim(),
language: 'de',
useRag
})
if (result && result.course && result.course.id) {
router.push(`/sdk/academy/${result.course.id}`)
} else {
router.push('/sdk/academy')
}
} catch (err: any) {
setError(err.message || 'Fehler bei der KI-Generierung')
} finally {
setIsLoading(false)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link
href="/sdk/academy"
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900">Neuen Kurs erstellen</h1>
<p className="text-sm text-gray-500 mt-1">
Erstellen Sie einen Compliance-Schulungskurs manuell oder lassen Sie ihn von der KI generieren.
</p>
</div>
</div>
{/* Mode Toggle */}
<div className="flex gap-2 bg-gray-100 p-1 rounded-xl w-fit">
<button
onClick={() => setMode('ai')}
className={`px-6 py-2.5 rounded-lg text-sm font-medium transition-all ${
mode === 'ai'
? 'bg-purple-600 text-white shadow-sm'
: 'text-gray-600 hover:text-gray-800'
}`}
>
<span className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
KI-Generierung
</span>
</button>
<button
onClick={() => setMode('manual')}
className={`px-6 py-2.5 rounded-lg text-sm font-medium transition-all ${
mode === 'manual'
? 'bg-purple-600 text-white shadow-sm'
: 'text-gray-600 hover:text-gray-800'
}`}
>
<span className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Manuell erstellen
</span>
</button>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-3">
<svg className="w-5 h-5 text-red-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-sm text-red-700">{error}</p>
</div>
)}
{/* AI Generation Form */}
{mode === 'ai' && (
<div className="bg-white rounded-xl border border-gray-200 p-8 space-y-6">
<div className="flex items-start gap-3 p-4 bg-purple-50 rounded-xl">
<svg className="w-5 h-5 text-purple-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div>
<h3 className="font-medium text-purple-800">KI-generierter Kurs</h3>
<p className="text-sm text-purple-600 mt-1">
Die KI erstellt automatisch Lektionen, Inhalte und Quizfragen basierend auf dem gewaehlten Thema.
Optionaler RAG-Kontext aus relevanten Gesetzestexten wird einbezogen.
</p>
</div>
</div>
{/* Topic */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Schulungsthema *</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="z.B. DSGVO-Grundlagen fuer neue Mitarbeiter"
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-base"
/>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{Object.entries(COURSE_CATEGORY_INFO).map(([cat, info]) => (
<button
key={cat}
type="button"
onClick={() => setCategory(cat as CourseCategory)}
className={`p-4 rounded-xl border-2 text-left transition-all ${
category === cat
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className={`text-sm font-medium ${category === cat ? 'text-purple-700' : 'text-gray-700'}`}>
{info.label}
</div>
<div className="text-xs text-gray-500 mt-1 line-clamp-2">{info.description}</div>
</button>
))}
</div>
</div>
{/* Target Group */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Zielgruppe</label>
<input
type="text"
value={targetGroup}
onChange={(e) => setTargetGroup(e.target.value)}
placeholder="z.B. Alle Mitarbeiter, IT-Abteilung, Fuehrungskraefte"
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
{/* RAG Toggle */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setUseRag(!useRag)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
useRag ? 'bg-purple-600' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
useRag ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<div>
<span className="text-sm font-medium text-gray-700">RAG-Kontext verwenden</span>
<p className="text-xs text-gray-500">Relevante Gesetzestexte (DSGVO, AI Act, NIS2) einbeziehen</p>
</div>
</div>
{/* Submit */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<Link
href="/sdk/academy"
className="px-6 py-2.5 text-gray-700 hover:bg-gray-100 rounded-xl transition-colors"
>
Abbrechen
</Link>
<button
onClick={handleAIGenerate}
disabled={isLoading || !topic.trim()}
className="px-6 py-2.5 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isLoading ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
KI generiert Kurs...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Kurs generieren
</>
)}
</button>
</div>
</div>
)}
{/* Manual Creation Form */}
{mode === 'manual' && (
<div className="bg-white rounded-xl border border-gray-200 p-8 space-y-6">
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Kurstitel *</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z.B. DSGVO-Grundlagen fuer Mitarbeiter"
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-base"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Kurze Beschreibung des Kursinhalts..."
rows={3}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none"
/>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value as CourseCategory)}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
{Object.entries(COURSE_CATEGORY_INFO).map(([cat, info]) => (
<option key={cat} value={cat}>{info.label}</option>
))}
</select>
</div>
{/* Duration & Passing Score */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Dauer (Minuten)</label>
<input
type="number"
value={duration}
onChange={(e) => setDuration(parseInt(e.target.value) || 60)}
min={15}
max={480}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Bestehensgrenze (%)</label>
<input
type="number"
value={passingScore}
onChange={(e) => setPassingScore(parseInt(e.target.value) || 70)}
min={0}
max={100}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
{/* Submit */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<Link
href="/sdk/academy"
className="px-6 py-2.5 text-gray-700 hover:bg-gray-100 rounded-xl transition-colors"
>
Abbrechen
</Link>
<button
onClick={handleManualCreate}
disabled={isLoading || !title.trim()}
className="px-6 py-2.5 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isLoading ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Wird erstellt...
</>
) : (
'Kurs erstellen'
)}
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,703 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
Course,
CourseCategory,
Enrollment,
EnrollmentStatus,
AcademyStatistics,
COURSE_CATEGORY_INFO,
ENROLLMENT_STATUS_INFO,
isEnrollmentOverdue,
getDaysUntilDeadline
} from '@/lib/sdk/academy/types'
import { fetchSDKAcademyList } from '@/lib/sdk/academy/api'
// =============================================================================
// TYPES
// =============================================================================
type TabId = 'overview' | 'courses' | 'enrollments' | 'certificates' | 'settings'
interface Tab {
id: TabId
label: string
count?: number
countColor?: string
}
// =============================================================================
// COMPONENTS
// =============================================================================
function TabNavigation({
tabs,
activeTab,
onTabChange
}: {
tabs: Tab[]
activeTab: TabId
onTabChange: (tab: TabId) => void
}) {
return (
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span className="flex items-center gap-2">
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className={`
px-2 py-0.5 text-xs rounded-full
${tab.countColor || 'bg-gray-100 text-gray-600'}
`}>
{tab.count}
</span>
)}
</span>
</button>
))}
</nav>
</div>
)
}
function StatCard({
label,
value,
color = 'gray',
icon,
trend
}: {
label: string
value: number | string
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
icon?: React.ReactNode
trend?: { value: number; label: string }
}) {
const colorClasses = {
gray: 'border-gray-200 text-gray-900',
blue: 'border-blue-200 text-blue-600',
yellow: 'border-yellow-200 text-yellow-600',
red: 'border-red-200 text-red-600',
green: 'border-green-200 text-green-600',
purple: 'border-purple-200 text-purple-600'
}
return (
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
<div className="flex items-start justify-between">
<div>
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : `text-${color}-600`}`}>
{label}
</div>
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
{value}
</div>
{trend && (
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
</div>
)}
</div>
{icon && (
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}>
{icon}
</div>
)}
</div>
</div>
)
}
function CourseCard({ course, enrollmentCount }: { course: Course; enrollmentCount: number }) {
const categoryInfo = COURSE_CATEGORY_INFO[course.category]
return (
<Link href={`/sdk/academy/${course.id}`}>
<div className={`
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
border-gray-200 hover:border-purple-300
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Header Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
{categoryInfo.label}
</span>
</div>
{/* Course Title */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{course.title}
</h3>
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{course.description}
</p>
{/* Course Meta */}
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
{course.lessons.length} Lektionen
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{course.durationMinutes} Min.
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{enrollmentCount} Teilnehmer
</span>
</div>
</div>
{/* Right Side - Roles */}
<div className="text-right ml-4 text-gray-500">
<div className="text-sm font-medium">
{course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`}
</div>
<div className="text-xs mt-0.5">
{new Date(course.updatedAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="text-sm text-gray-500">
Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')}
</div>
<div className="flex items-center gap-2">
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Details
</span>
</div>
</div>
</div>
</Link>
)
}
function EnrollmentCard({ enrollment, courseName }: { enrollment: Enrollment; courseName: string }) {
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
const overdue = isEnrollmentOverdue(enrollment)
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
return (
<div className={`
bg-white rounded-xl border-2 p-6
${overdue ? 'border-red-300' :
enrollment.status === 'completed' ? 'border-green-200' :
enrollment.status === 'in_progress' ? 'border-yellow-200' :
'border-gray-200'
}
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Status Badge */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
{statusInfo.label}
</span>
{overdue && (
<span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Ueberfaellig
</span>
)}
</div>
{/* User Info */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{enrollment.userName}
</h3>
<p className="text-sm text-gray-500">{enrollment.userEmail}</p>
<p className="text-sm text-gray-600 mt-1 font-medium">{courseName}</p>
{/* Progress Bar */}
<div className="mt-3">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-500">Fortschritt</span>
<span className="font-medium text-gray-700">{enrollment.progress}%</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
enrollment.progress === 100 ? 'bg-green-500' :
overdue ? 'bg-red-500' :
'bg-purple-500'
}`}
style={{ width: `${enrollment.progress}%` }}
/>
</div>
</div>
</div>
{/* Right Side - Deadline */}
<div className={`text-right ml-4 ${
overdue ? 'text-red-600' :
daysUntil <= 7 ? 'text-orange-600' :
'text-gray-500'
}`}>
<div className="text-sm font-medium">
{enrollment.status === 'completed'
? 'Abgeschlossen'
: overdue
? `${Math.abs(daysUntil)} Tage ueberfaellig`
: `${daysUntil} Tage verbleibend`
}
</div>
<div className="text-xs mt-0.5">
Frist: {new Date(enrollment.deadline).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="text-sm text-gray-500">
Gestartet: {new Date(enrollment.startedAt).toLocaleDateString('de-DE')}
</div>
{enrollment.completedAt && (
<div className="text-sm text-green-600">
Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')}
</div>
)}
</div>
</div>
)
}
function FilterBar({
selectedCategory,
selectedStatus,
onCategoryChange,
onStatusChange,
onClear
}: {
selectedCategory: CourseCategory | 'all'
selectedStatus: EnrollmentStatus | 'all'
onCategoryChange: (category: CourseCategory | 'all') => void
onStatusChange: (status: EnrollmentStatus | 'all') => void
onClear: () => void
}) {
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all'
return (
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{/* Category Filter */}
<select
value={selectedCategory}
onChange={(e) => onCategoryChange(e.target.value as CourseCategory | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Kategorien</option>
{Object.entries(COURSE_CATEGORY_INFO).map(([cat, info]) => (
<option key={cat} value={cat}>{info.label}</option>
))}
</select>
{/* Enrollment Status Filter */}
<select
value={selectedStatus}
onChange={(e) => onStatusChange(e.target.value as EnrollmentStatus | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Status</option>
{Object.entries(ENROLLMENT_STATUS_INFO).map(([status, info]) => (
<option key={status} value={status}>{info.label}</option>
))}
</select>
{/* Clear Filters */}
{hasFilters && (
<button
onClick={onClear}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function AcademyPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [courses, setCourses] = useState<Course[]>([])
const [enrollments, setEnrollments] = useState<Enrollment[]>([])
const [statistics, setStatistics] = useState<AcademyStatistics | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Filters
const [selectedCategory, setSelectedCategory] = useState<CourseCategory | 'all'>('all')
const [selectedStatus, setSelectedStatus] = useState<EnrollmentStatus | 'all'>('all')
// Load data from SDK backend
useEffect(() => {
const loadData = async () => {
setIsLoading(true)
try {
const data = await fetchSDKAcademyList()
setCourses(data.courses)
setEnrollments(data.enrollments)
setStatistics(data.statistics)
} catch (error) {
console.error('Failed to load Academy data:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
// Calculate tab counts
const tabCounts = useMemo(() => {
return {
courses: courses.length,
enrollments: enrollments.filter(e => e.status !== 'completed').length,
certificates: enrollments.filter(e => e.certificateId).length,
overdue: enrollments.filter(e => isEnrollmentOverdue(e)).length
}
}, [courses, enrollments])
// Filtered courses
const filteredCourses = useMemo(() => {
let filtered = [...courses]
if (selectedCategory !== 'all') {
filtered = filtered.filter(c => c.category === selectedCategory)
}
return filtered.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
}, [courses, selectedCategory])
// Filtered enrollments
const filteredEnrollments = useMemo(() => {
let filtered = [...enrollments]
if (selectedStatus !== 'all') {
filtered = filtered.filter(e => e.status === selectedStatus)
}
// Sort: overdue first, then by deadline
return filtered.sort((a, b) => {
const aOverdue = isEnrollmentOverdue(a) ? -1 : 0
const bOverdue = isEnrollmentOverdue(b) ? -1 : 0
if (aOverdue !== bOverdue) return aOverdue - bOverdue
return getDaysUntilDeadline(a.deadline) - getDaysUntilDeadline(b.deadline)
})
}, [enrollments, selectedStatus])
// Enrollment counts per course
const enrollmentCountByCourseId = useMemo(() => {
const counts: Record<string, number> = {}
enrollments.forEach(e => {
counts[e.courseId] = (counts[e.courseId] || 0) + 1
})
return counts
}, [enrollments])
// Course name lookup
const courseNameById = useMemo(() => {
const map: Record<string, string> = {}
courses.forEach(c => { map[c.id] = c.title })
return map
}, [courses])
const tabs: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'courses', label: 'Kurse', count: tabCounts.courses, countColor: 'bg-blue-100 text-blue-600' },
{ id: 'enrollments', label: 'Einschreibungen', count: tabCounts.enrollments, countColor: 'bg-yellow-100 text-yellow-600' },
{ id: 'certificates', label: 'Zertifikate', count: tabCounts.certificates, countColor: 'bg-green-100 text-green-600' },
{ id: 'settings', label: 'Einstellungen' }
]
const stepInfo = STEP_EXPLANATIONS['academy']
const clearFilters = () => {
setSelectedCategory('all')
setSelectedStatus('all')
}
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="academy"
title={stepInfo?.title || 'Compliance Academy'}
description={stepInfo?.description || 'E-Learning Plattform fuer Compliance-Schulungen'}
explanation={stepInfo?.explanation}
tips={stepInfo?.tips}
>
<Link
href="/sdk/academy/new"
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Kurs erstellen
</Link>
</StepHeader>
{/* Tab Navigation */}
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Loading State */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : activeTab === 'settings' ? (
/* Settings Tab */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
<p className="mt-2 text-gray-500">
Academy-Einstellungen, E-Mail-Benachrichtigungen und Kurs-Vorlagen
werden in einer spaeteren Version verfuegbar sein.
</p>
</div>
) : activeTab === 'certificates' ? (
/* Certificates Tab Placeholder */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Zertifikate</h3>
<p className="mt-2 text-gray-500">
Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert.
Die Zertifikatsverwaltung wird in einer spaeteren Version verfuegbar sein.
</p>
{tabCounts.certificates > 0 && (
<p className="mt-2 text-sm text-purple-600 font-medium">
{tabCounts.certificates} Zertifikat(e) vorhanden
</p>
)}
</div>
) : (
<>
{/* Statistics (Overview Tab) */}
{activeTab === 'overview' && statistics && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Kurse gesamt"
value={statistics.totalCourses}
color="gray"
/>
<StatCard
label="Aktive Teilnehmer"
value={statistics.byStatus.in_progress + statistics.byStatus.not_started}
color="blue"
/>
<StatCard
label="Abschlussrate"
value={`${statistics.completionRate}%`}
color="green"
/>
<StatCard
label="Ueberfaellig"
value={statistics.overdueCount}
color={statistics.overdueCount > 0 ? 'red' : 'green'}
/>
</div>
)}
{/* Overdue Alert */}
{tabCounts.overdue > 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<h4 className="font-medium text-red-800">
Achtung: {tabCounts.overdue} ueberfaellige Schulung(en)
</h4>
<p className="text-sm text-red-600">
Mitarbeiter haben Pflichtschulungen nicht fristgerecht abgeschlossen. Handeln Sie umgehend.
</p>
</div>
<button
onClick={() => {
setActiveTab('enrollments')
setSelectedStatus('all')
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Anzeigen
</button>
</div>
)}
{/* Info Box (Overview Tab) */}
{activeTab === 'overview' && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-medium text-blue-800">Schulungspflicht nach Art. 39 DSGVO</h4>
<p className="text-sm text-blue-600 mt-1">
Gemaess Art. 39 Abs. 1 lit. b DSGVO gehoert die Sensibilisierung und Schulung
der an den Verarbeitungsvorgaengen beteiligten Mitarbeiter zu den Aufgaben des
Datenschutzbeauftragten. Nachweisbare Compliance-Schulungen sind Pflicht und
sollten mindestens jaehrlich aufgefrischt werden.
</p>
</div>
</div>
</div>
)}
{/* Filters */}
<FilterBar
selectedCategory={selectedCategory}
selectedStatus={selectedStatus}
onCategoryChange={setSelectedCategory}
onStatusChange={setSelectedStatus}
onClear={clearFilters}
/>
{/* Courses Tab */}
{(activeTab === 'overview' || activeTab === 'courses') && (
<div className="space-y-4">
{activeTab === 'courses' && (
<h2 className="text-lg font-semibold text-gray-900">Kurse ({filteredCourses.length})</h2>
)}
{filteredCourses.map(course => (
<CourseCard
key={course.id}
course={course}
enrollmentCount={enrollmentCountByCourseId[course.id] || 0}
/>
))}
</div>
)}
{/* Enrollments Tab */}
{activeTab === 'enrollments' && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Einschreibungen ({filteredEnrollments.length})</h2>
{filteredEnrollments.map(enrollment => (
<EnrollmentCard
key={enrollment.id}
enrollment={enrollment}
courseName={courseNameById[enrollment.courseId] || 'Unbekannter Kurs'}
/>
))}
</div>
)}
{/* Empty States */}
{activeTab === 'courses' && filteredCourses.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Kurse gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedCategory !== 'all'
? 'Passen Sie die Filter an oder'
: 'Es sind noch keine Kurse vorhanden.'
}
</p>
{selectedCategory !== 'all' ? (
<button
onClick={clearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
) : (
<Link
href="/sdk/academy/new"
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Ersten Kurs erstellen
</Link>
)}
</div>
)}
{activeTab === 'enrollments' && filteredEnrollments.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Einschreibungen gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedStatus !== 'all'
? 'Passen Sie die Filter an.'
: 'Es sind noch keine Mitarbeiter in Kurse eingeschrieben.'
}
</p>
{selectedStatus !== 'all' && (
<button
onClick={clearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,839 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
// =============================================================================
// TYPES
// =============================================================================
interface CrawlSource {
id: string
name: string
source_type: string
path: string
file_extensions: string[]
max_depth: number
exclude_patterns: string[]
enabled: boolean
created_at: string
}
interface CrawlJob {
id: string
source_id: string
source_name?: string
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
job_type: 'full' | 'delta'
files_found: number
files_processed: number
files_new: number
files_changed: number
files_skipped: number
files_error: number
error_message?: string
started_at?: string
completed_at?: string
created_at: string
}
interface CrawlDocument {
id: string
file_name: string
file_extension: string
file_size_bytes: number
classification: string | null
classification_confidence: number | null
classification_corrected: boolean
extraction_status: string
archived: boolean
ipfs_cid: string | null
first_seen_at: string
last_seen_at: string
version_count: number
source_name?: string
}
interface OnboardingReport {
id: string
total_documents_found: number
classification_breakdown: Record<string, number>
gaps: GapItem[]
compliance_score: number
gap_summary?: { critical: number; high: number; medium: number }
created_at: string
}
interface GapItem {
id: string
category: string
description: string
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM'
regulation: string
requiredAction: string
}
// =============================================================================
// API HELPERS
// =============================================================================
const TENANT_ID = '00000000-0000-0000-0000-000000000001' // Default tenant
async function api(path: string, options: RequestInit = {}) {
const res = await fetch(`/api/sdk/v1/crawler/${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': TENANT_ID,
...options.headers,
},
})
if (res.status === 204) return null
return res.json()
}
// =============================================================================
// CLASSIFICATION LABELS
// =============================================================================
const CLASSIFICATION_LABELS: Record<string, { label: string; color: string }> = {
VVT: { label: 'VVT', color: 'bg-blue-100 text-blue-700' },
TOM: { label: 'TOM', color: 'bg-green-100 text-green-700' },
DSE: { label: 'DSE', color: 'bg-purple-100 text-purple-700' },
AVV: { label: 'AVV', color: 'bg-orange-100 text-orange-700' },
DSFA: { label: 'DSFA', color: 'bg-red-100 text-red-700' },
Loeschkonzept: { label: 'Loeschkonzept', color: 'bg-yellow-100 text-yellow-700' },
Einwilligung: { label: 'Einwilligung', color: 'bg-pink-100 text-pink-700' },
Vertrag: { label: 'Vertrag', color: 'bg-indigo-100 text-indigo-700' },
Richtlinie: { label: 'Richtlinie', color: 'bg-teal-100 text-teal-700' },
Schulungsnachweis: { label: 'Schulung', color: 'bg-cyan-100 text-cyan-700' },
Sonstiges: { label: 'Sonstiges', color: 'bg-gray-100 text-gray-700' },
}
const ALL_CLASSIFICATIONS = Object.keys(CLASSIFICATION_LABELS)
// =============================================================================
// TAB: QUELLEN (Sources)
// =============================================================================
function SourcesTab() {
const [sources, setSources] = useState<CrawlSource[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [formName, setFormName] = useState('')
const [formPath, setFormPath] = useState('')
const [testResult, setTestResult] = useState<Record<string, string>>({})
const loadSources = useCallback(async () => {
setLoading(true)
try {
const data = await api('sources')
setSources(data || [])
} catch { /* ignore */ }
setLoading(false)
}, [])
useEffect(() => { loadSources() }, [loadSources])
const handleCreate = async () => {
if (!formName || !formPath) return
await api('sources', {
method: 'POST',
body: JSON.stringify({ name: formName, path: formPath }),
})
setFormName('')
setFormPath('')
setShowForm(false)
loadSources()
}
const handleDelete = async (id: string) => {
await api(`sources/${id}`, { method: 'DELETE' })
loadSources()
}
const handleToggle = async (source: CrawlSource) => {
await api(`sources/${source.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: !source.enabled }),
})
loadSources()
}
const handleTest = async (id: string) => {
setTestResult(prev => ({ ...prev, [id]: 'testing...' }))
const result = await api(`sources/${id}/test`, { method: 'POST' })
setTestResult(prev => ({ ...prev, [id]: result?.message || 'Fehler' }))
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-gray-900">Crawl-Quellen</h2>
<button
onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700"
>
+ Neue Quelle
</button>
</div>
{showForm && (
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
value={formName}
onChange={e => setFormName(e.target.value)}
placeholder="z.B. Compliance-Ordner"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Pfad (relativ zu /data/crawl)</label>
<input
value={formPath}
onChange={e => setFormPath(e.target.value)}
placeholder="z.B. compliance-docs"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
/>
</div>
<div className="flex gap-2">
<button onClick={handleCreate} className="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700">
Erstellen
</button>
<button onClick={() => setShowForm(false)} className="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">
Abbrechen
</button>
</div>
</div>
)}
{loading ? (
<div className="text-center py-12 text-gray-500">Laden...</div>
) : sources.length === 0 ? (
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
<p className="text-lg font-medium">Keine Quellen konfiguriert</p>
<p className="text-sm mt-1">Erstellen Sie eine Crawl-Quelle um Dokumente zu scannen.</p>
</div>
) : (
<div className="space-y-3">
{sources.map(s => (
<div key={s.id} className="bg-white rounded-xl border border-gray-200 p-5 flex items-center gap-4">
<div className={`w-3 h-3 rounded-full ${s.enabled ? 'bg-green-500' : 'bg-gray-300'}`} />
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{s.name}</div>
<div className="text-sm text-gray-500 truncate">{s.path}</div>
<div className="text-xs text-gray-400 mt-1">
Tiefe: {s.max_depth} | Formate: {(typeof s.file_extensions === 'string' ? JSON.parse(s.file_extensions) : s.file_extensions).join(', ')}
</div>
</div>
{testResult[s.id] && (
<span className="text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded">{testResult[s.id]}</span>
)}
<button onClick={() => handleTest(s.id)} className="text-sm text-blue-600 hover:text-blue-800">Testen</button>
<button onClick={() => handleToggle(s)} className="text-sm text-gray-600 hover:text-gray-800">
{s.enabled ? 'Deaktivieren' : 'Aktivieren'}
</button>
<button onClick={() => handleDelete(s.id)} className="text-sm text-red-600 hover:text-red-800">Loeschen</button>
</div>
))}
</div>
)}
</div>
)
}
// =============================================================================
// TAB: CRAWL-JOBS
// =============================================================================
function JobsTab() {
const [jobs, setJobs] = useState<CrawlJob[]>([])
const [sources, setSources] = useState<CrawlSource[]>([])
const [selectedSource, setSelectedSource] = useState('')
const [jobType, setJobType] = useState<'full' | 'delta'>('full')
const [loading, setLoading] = useState(true)
const loadData = useCallback(async () => {
setLoading(true)
try {
const [j, s] = await Promise.all([api('jobs'), api('sources')])
setJobs(j || [])
setSources(s || [])
if (!selectedSource && s?.length > 0) setSelectedSource(s[0].id)
} catch { /* ignore */ }
setLoading(false)
}, [selectedSource])
useEffect(() => { loadData() }, [loadData])
// Auto-refresh running jobs
useEffect(() => {
const hasRunning = jobs.some(j => j.status === 'running' || j.status === 'pending')
if (!hasRunning) return
const interval = setInterval(loadData, 3000)
return () => clearInterval(interval)
}, [jobs, loadData])
const handleTrigger = async () => {
if (!selectedSource) return
await api('jobs', {
method: 'POST',
body: JSON.stringify({ source_id: selectedSource, job_type: jobType }),
})
loadData()
}
const handleCancel = async (id: string) => {
await api(`jobs/${id}/cancel`, { method: 'POST' })
loadData()
}
const statusColor = (s: string) => {
switch (s) {
case 'completed': return 'bg-green-100 text-green-700'
case 'running': return 'bg-blue-100 text-blue-700'
case 'pending': return 'bg-yellow-100 text-yellow-700'
case 'failed': return 'bg-red-100 text-red-700'
case 'cancelled': return 'bg-gray-100 text-gray-600'
default: return 'bg-gray-100 text-gray-700'
}
}
return (
<div className="space-y-6">
{/* Trigger form */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-medium text-gray-900 mb-4">Neuen Crawl starten</h3>
<div className="flex gap-4 items-end">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">Quelle</label>
<select
value={selectedSource}
onChange={e => setSelectedSource(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
{sources.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
<select
value={jobType}
onChange={e => setJobType(e.target.value as 'full' | 'delta')}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="full">Voll-Scan</option>
<option value="delta">Delta-Scan</option>
</select>
</div>
<button
onClick={handleTrigger}
disabled={!selectedSource}
className="px-6 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
Crawl starten
</button>
</div>
</div>
{/* Job list */}
{loading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : jobs.length === 0 ? (
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
Noch keine Crawl-Jobs ausgefuehrt.
</div>
) : (
<div className="space-y-3">
{jobs.map(job => (
<div key={job.id} className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 text-xs font-medium rounded ${statusColor(job.status)}`}>
{job.status}
</span>
<span className="text-sm font-medium text-gray-900">{job.source_name || 'Quelle'}</span>
<span className="text-xs text-gray-400">{job.job_type === 'delta' ? 'Delta' : 'Voll'}</span>
</div>
<div className="flex items-center gap-2">
{(job.status === 'running' || job.status === 'pending') && (
<button onClick={() => handleCancel(job.id)} className="text-xs text-red-600 hover:text-red-800">
Abbrechen
</button>
)}
<span className="text-xs text-gray-400">
{new Date(job.created_at).toLocaleString('de-DE')}
</span>
</div>
</div>
{/* Progress */}
{job.status === 'running' && job.files_found > 0 && (
<div className="mb-3">
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 rounded-full transition-all"
style={{ width: `${(job.files_processed / job.files_found) * 100}%` }}
/>
</div>
<div className="text-xs text-gray-500 mt-1">
{job.files_processed} / {job.files_found} Dateien verarbeitet
</div>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-6 gap-2 text-center">
<div className="bg-gray-50 rounded-lg p-2">
<div className="text-lg font-bold text-gray-900">{job.files_found}</div>
<div className="text-xs text-gray-500">Gefunden</div>
</div>
<div className="bg-gray-50 rounded-lg p-2">
<div className="text-lg font-bold text-gray-900">{job.files_processed}</div>
<div className="text-xs text-gray-500">Verarbeitet</div>
</div>
<div className="bg-green-50 rounded-lg p-2">
<div className="text-lg font-bold text-green-700">{job.files_new}</div>
<div className="text-xs text-green-600">Neu</div>
</div>
<div className="bg-blue-50 rounded-lg p-2">
<div className="text-lg font-bold text-blue-700">{job.files_changed}</div>
<div className="text-xs text-blue-600">Geaendert</div>
</div>
<div className="bg-gray-50 rounded-lg p-2">
<div className="text-lg font-bold text-gray-500">{job.files_skipped}</div>
<div className="text-xs text-gray-500">Uebersprungen</div>
</div>
<div className="bg-red-50 rounded-lg p-2">
<div className="text-lg font-bold text-red-700">{job.files_error}</div>
<div className="text-xs text-red-600">Fehler</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
// =============================================================================
// TAB: DOKUMENTE
// =============================================================================
function DocumentsTab() {
const [docs, setDocs] = useState<CrawlDocument[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [filterClass, setFilterClass] = useState('')
const [archiving, setArchiving] = useState<Record<string, boolean>>({})
const loadDocs = useCallback(async () => {
setLoading(true)
try {
const params = filterClass ? `?classification=${filterClass}` : ''
const data = await api(`documents${params}`)
setDocs(data?.documents || [])
setTotal(data?.total || 0)
} catch { /* ignore */ }
setLoading(false)
}, [filterClass])
useEffect(() => { loadDocs() }, [loadDocs])
const handleReclassify = async (docId: string, newClass: string) => {
await api(`documents/${docId}/classify`, {
method: 'PUT',
body: JSON.stringify({ classification: newClass }),
})
loadDocs()
}
const handleArchive = async (docId: string) => {
setArchiving(prev => ({ ...prev, [docId]: true }))
try {
await api(`documents/${docId}/archive`, { method: 'POST' })
loadDocs()
} catch { /* ignore */ }
setArchiving(prev => ({ ...prev, [docId]: false }))
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">{total} Dokumente</h2>
<select
value={filterClass}
onChange={e => setFilterClass(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">Alle Kategorien</option>
{ALL_CLASSIFICATIONS.map(c => (
<option key={c} value={c}>{CLASSIFICATION_LABELS[c]?.label || c}</option>
))}
</select>
</div>
{loading ? (
<div className="text-center py-12 text-gray-500">Laden...</div>
) : docs.length === 0 ? (
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
Keine Dokumente gefunden. Starten Sie zuerst einen Crawl-Job.
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="text-left px-4 py-3 font-medium">Datei</th>
<th className="text-left px-4 py-3 font-medium">Kategorie</th>
<th className="text-center px-4 py-3 font-medium">Konfidenz</th>
<th className="text-right px-4 py-3 font-medium">Groesse</th>
<th className="text-center px-4 py-3 font-medium">Archiv</th>
<th className="text-right px-4 py-3 font-medium">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{docs.map(doc => {
const cls = CLASSIFICATION_LABELS[doc.classification || ''] || CLASSIFICATION_LABELS['Sonstiges']
return (
<tr key={doc.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="font-medium text-gray-900 truncate max-w-xs">{doc.file_name}</div>
<div className="text-xs text-gray-400">{doc.source_name}</div>
</td>
<td className="px-4 py-3">
<select
value={doc.classification || 'Sonstiges'}
onChange={e => handleReclassify(doc.id, e.target.value)}
className={`px-2 py-1 text-xs font-medium rounded border-0 ${cls.color}`}
>
{ALL_CLASSIFICATIONS.map(c => (
<option key={c} value={c}>{CLASSIFICATION_LABELS[c].label}</option>
))}
</select>
{doc.classification_corrected && (
<span className="ml-1 text-xs text-orange-500" title="Manuell korrigiert">*</span>
)}
</td>
<td className="px-4 py-3 text-center">
{doc.classification_confidence != null && (
<div className="inline-flex items-center gap-1">
<div className="w-12 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-500 rounded-full"
style={{ width: `${doc.classification_confidence * 100}%` }}
/>
</div>
<span className="text-xs text-gray-500">
{(doc.classification_confidence * 100).toFixed(0)}%
</span>
</div>
)}
</td>
<td className="px-4 py-3 text-right text-gray-500">{formatSize(doc.file_size_bytes)}</td>
<td className="px-4 py-3 text-center">
{doc.archived ? (
<span className="text-green-600 text-xs font-medium" title={doc.ipfs_cid || ''}>IPFS</span>
) : (
<span className="text-gray-400 text-xs">-</span>
)}
</td>
<td className="px-4 py-3 text-right">
{!doc.archived && (
<button
onClick={() => handleArchive(doc.id)}
disabled={archiving[doc.id]}
className="text-xs text-purple-600 hover:text-purple-800 disabled:opacity-50"
>
{archiving[doc.id] ? 'Archiviert...' : 'Archivieren'}
</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
// =============================================================================
// TAB: ONBOARDING-REPORT
// =============================================================================
function ReportTab() {
const [reports, setReports] = useState<OnboardingReport[]>([])
const [activeReport, setActiveReport] = useState<OnboardingReport | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const loadReports = useCallback(async () => {
setLoading(true)
try {
const data = await api('reports')
setReports(data || [])
if (data?.length > 0 && !activeReport) {
const detail = await api(`reports/${data[0].id}`)
setActiveReport(detail)
}
} catch { /* ignore */ }
setLoading(false)
}, [activeReport])
useEffect(() => { loadReports() }, [loadReports])
const handleGenerate = async () => {
setGenerating(true)
try {
const result = await api('reports/generate', {
method: 'POST',
body: JSON.stringify({}),
})
setActiveReport(result)
loadReports()
} catch { /* ignore */ }
setGenerating(false)
}
const handleSelectReport = async (id: string) => {
const detail = await api(`reports/${id}`)
setActiveReport(detail)
}
// Compliance score ring
const ComplianceRing = ({ score }: { score: number }) => {
const radius = 50
const circumference = 2 * Math.PI * radius
const offset = circumference - (score / 100) * circumference
const color = score >= 75 ? '#16a34a' : score >= 50 ? '#f59e0b' : '#dc2626'
return (
<div className="relative w-36 h-36">
<svg className="w-full h-full -rotate-90">
<circle cx="68" cy="68" r={radius} fill="none" stroke="#e5e7eb" strokeWidth="8" />
<circle
cx="68" cy="68" r={radius} fill="none"
stroke={color} strokeWidth="8"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-1000"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold" style={{ color }}>{score.toFixed(0)}%</span>
<span className="text-xs text-gray-500">Compliance</span>
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Onboarding-Report</h2>
<button
onClick={handleGenerate}
disabled={generating}
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{generating ? 'Generiere...' : 'Neuen Report erstellen'}
</button>
</div>
{/* Report selector */}
{reports.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-2">
{reports.map(r => (
<button
key={r.id}
onClick={() => handleSelectReport(r.id)}
className={`px-3 py-1.5 text-xs rounded-lg border whitespace-nowrap ${
activeReport?.id === r.id
? 'bg-purple-50 border-purple-300 text-purple-700'
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{new Date(r.created_at).toLocaleString('de-DE')} {r.compliance_score.toFixed(0)}%
</button>
))}
</div>
)}
{loading ? (
<div className="text-center py-12 text-gray-500">Laden...</div>
) : !activeReport ? (
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
<p className="text-lg font-medium">Kein Report vorhanden</p>
<p className="text-sm mt-1">Fuehren Sie zuerst einen Crawl durch und generieren Sie dann einen Report.</p>
</div>
) : (
<div className="space-y-6">
{/* Score + Stats */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-8">
<ComplianceRing score={activeReport.compliance_score} />
<div className="flex-1 grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-gray-50 rounded-xl">
<div className="text-3xl font-bold text-gray-900">{activeReport.total_documents_found}</div>
<div className="text-sm text-gray-500">Dokumente gefunden</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-xl">
<div className="text-3xl font-bold text-gray-900">
{Object.keys(activeReport.classification_breakdown || {}).length}
</div>
<div className="text-sm text-gray-500">Kategorien abgedeckt</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-xl">
<div className="text-3xl font-bold text-red-600">
{(activeReport.gaps || []).length}
</div>
<div className="text-sm text-gray-500">Luecken identifiziert</div>
</div>
</div>
</div>
</div>
{/* Classification breakdown */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-medium text-gray-900 mb-4">Dokumenten-Verteilung</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(activeReport.classification_breakdown || {}).map(([cat, count]) => {
const cls = CLASSIFICATION_LABELS[cat] || CLASSIFICATION_LABELS['Sonstiges']
return (
<span key={cat} className={`px-3 py-1.5 text-sm font-medium rounded-lg ${cls.color}`}>
{cls.label}: {count as number}
</span>
)
})}
{Object.keys(activeReport.classification_breakdown || {}).length === 0 && (
<span className="text-gray-400 text-sm">Keine Dokumente klassifiziert</span>
)}
</div>
</div>
{/* Gap summary */}
{activeReport.gap_summary && (
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-red-50 rounded-xl border border-red-100">
<div className="text-3xl font-bold text-red-600">{activeReport.gap_summary.critical}</div>
<div className="text-sm text-red-600 font-medium">Kritisch</div>
</div>
<div className="text-center p-4 bg-orange-50 rounded-xl border border-orange-100">
<div className="text-3xl font-bold text-orange-600">{activeReport.gap_summary.high}</div>
<div className="text-sm text-orange-600 font-medium">Hoch</div>
</div>
<div className="text-center p-4 bg-yellow-50 rounded-xl border border-yellow-100">
<div className="text-3xl font-bold text-yellow-600">{activeReport.gap_summary.medium}</div>
<div className="text-sm text-yellow-600 font-medium">Mittel</div>
</div>
</div>
)}
{/* Gap details */}
{(activeReport.gaps || []).length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-medium text-gray-900 mb-4">Compliance-Luecken</h3>
<div className="space-y-3">
{activeReport.gaps.map((gap) => (
<div
key={gap.id}
className={`p-4 rounded-lg border-l-4 ${
gap.severity === 'CRITICAL'
? 'bg-red-50 border-red-500'
: gap.severity === 'HIGH'
? 'bg-orange-50 border-orange-500'
: 'bg-yellow-50 border-yellow-500'
}`}
>
<div className="flex items-start justify-between">
<div>
<div className="font-medium text-gray-900">{gap.category}</div>
<p className="text-sm text-gray-600 mt-1">{gap.description}</p>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded ${
gap.severity === 'CRITICAL' ? 'bg-red-100 text-red-700'
: gap.severity === 'HIGH' ? 'bg-orange-100 text-orange-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{gap.severity}
</span>
</div>
<div className="mt-2 text-xs text-gray-500">
Regulierung: {gap.regulation} | Aktion: {gap.requiredAction}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
type Tab = 'sources' | 'jobs' | 'documents' | 'report'
export default function DocumentCrawlerPage() {
const [activeTab, setActiveTab] = useState<Tab>('sources')
const tabs: { id: Tab; label: string }[] = [
{ id: 'sources', label: 'Quellen' },
{ id: 'jobs', label: 'Crawl-Jobs' },
{ id: 'documents', label: 'Dokumente' },
{ id: 'report', label: 'Onboarding-Report' },
]
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Document Crawler & Auto-Onboarding</h1>
<p className="mt-1 text-gray-500">
Automatisches Scannen von Dateisystemen, KI-Klassifizierung, IPFS-Archivierung und Compliance Gap-Analyse.
</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="flex gap-6">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab content */}
{activeTab === 'sources' && <SourcesTab />}
{activeTab === 'jobs' && <JobsTab />}
{activeTab === 'documents' && <DocumentsTab />}
{activeTab === 'report' && <ReportTab />}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -307,7 +307,7 @@ export default function DSFAPage() {
// Open document in workflow editor
const handleOpenInEditor = useCallback((doc: UploadedDocument) => {
router.push(`/compliance/workflow?documentType=dsfa&documentId=${doc.id}&mode=change`)
router.push(`/sdk/workflow?documentType=dsfa&documentId=${doc.id}&mode=change`)
}, [router])
const filteredDSFAs = filter === 'all'

View File

@@ -0,0 +1,706 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
Incident,
IncidentSeverity,
IncidentStatus,
IncidentCategory,
IncidentStatistics,
INCIDENT_SEVERITY_INFO,
INCIDENT_STATUS_INFO,
INCIDENT_CATEGORY_INFO,
getHoursUntil72hDeadline,
is72hDeadlineExpired
} from '@/lib/sdk/incidents/types'
import { fetchSDKIncidentList, createMockIncidents, createMockStatistics } from '@/lib/sdk/incidents/api'
// =============================================================================
// TYPES
// =============================================================================
type TabId = 'overview' | 'active' | 'notification' | 'closed' | 'settings'
interface Tab {
id: TabId
label: string
count?: number
countColor?: string
}
// =============================================================================
// COMPONENTS
// =============================================================================
function TabNavigation({
tabs,
activeTab,
onTabChange
}: {
tabs: Tab[]
activeTab: TabId
onTabChange: (tab: TabId) => void
}) {
return (
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span className="flex items-center gap-2">
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className={`
px-2 py-0.5 text-xs rounded-full
${tab.countColor || 'bg-gray-100 text-gray-600'}
`}>
{tab.count}
</span>
)}
</span>
</button>
))}
</nav>
</div>
)
}
function StatCard({
label,
value,
color = 'gray',
icon,
trend
}: {
label: string
value: number | string
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple' | 'orange'
icon?: React.ReactNode
trend?: { value: number; label: string }
}) {
const colorClasses: Record<string, string> = {
gray: 'border-gray-200 text-gray-900',
blue: 'border-blue-200 text-blue-600',
yellow: 'border-yellow-200 text-yellow-600',
red: 'border-red-200 text-red-600',
green: 'border-green-200 text-green-600',
purple: 'border-purple-200 text-purple-600',
orange: 'border-orange-200 text-orange-600'
}
return (
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
<div className="flex items-start justify-between">
<div>
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : ''}`}>
{label}
</div>
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
{value}
</div>
{trend && (
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
</div>
)}
</div>
{icon && (
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-gray-50">
{icon}
</div>
)}
</div>
</div>
)
}
function FilterBar({
selectedSeverity,
selectedStatus,
selectedCategory,
onSeverityChange,
onStatusChange,
onCategoryChange,
onClear
}: {
selectedSeverity: IncidentSeverity | 'all'
selectedStatus: IncidentStatus | 'all'
selectedCategory: IncidentCategory | 'all'
onSeverityChange: (severity: IncidentSeverity | 'all') => void
onStatusChange: (status: IncidentStatus | 'all') => void
onCategoryChange: (category: IncidentCategory | 'all') => void
onClear: () => void
}) {
const hasFilters = selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all'
return (
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{/* Severity Filter */}
<select
value={selectedSeverity}
onChange={(e) => onSeverityChange(e.target.value as IncidentSeverity | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Schweregrade</option>
{Object.entries(INCIDENT_SEVERITY_INFO).map(([severity, info]) => (
<option key={severity} value={severity}>{info.label}</option>
))}
</select>
{/* Status Filter */}
<select
value={selectedStatus}
onChange={(e) => onStatusChange(e.target.value as IncidentStatus | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Status</option>
{Object.entries(INCIDENT_STATUS_INFO).map(([status, info]) => (
<option key={status} value={status}>{info.label}</option>
))}
</select>
{/* Category Filter */}
<select
value={selectedCategory}
onChange={(e) => onCategoryChange(e.target.value as IncidentCategory | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Kategorien</option>
{Object.entries(INCIDENT_CATEGORY_INFO).map(([cat, info]) => (
<option key={cat} value={cat}>{info.label}</option>
))}
</select>
{/* Clear Filters */}
{hasFilters && (
<button
onClick={onClear}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)
}
/**
* 72h-Countdown-Anzeige mit visueller Farbkodierung
* Gruen > 48h, Gelb > 24h, Orange > 12h, Rot < 12h oder abgelaufen
*/
function CountdownTimer({ incident }: { incident: Incident }) {
const hoursRemaining = getHoursUntil72hDeadline(incident.detectedAt)
const expired = is72hDeadlineExpired(incident.detectedAt)
// Nicht relevant fuer abgeschlossene Vorfaelle
if (incident.status === 'closed') return null
// Bereits gemeldet
if (incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')) {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Gemeldet
</span>
)
}
// Keine Meldepflicht festgestellt
if (incident.riskAssessment && !incident.riskAssessment.notificationRequired) {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded-full">
Keine Meldepflicht
</span>
)
}
// Abgelaufen
if (expired) {
const overdueHours = Math.abs(hoursRemaining)
return (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-bold bg-red-100 text-red-700 rounded-full animate-pulse">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{overdueHours.toFixed(0)}h ueberfaellig
</span>
)
}
// Farbkodierung: gruen > 48h, gelb > 24h, orange > 12h, rot < 12h
let colorClass: string
if (hoursRemaining > 48) {
colorClass = 'bg-green-100 text-green-700'
} else if (hoursRemaining > 24) {
colorClass = 'bg-yellow-100 text-yellow-700'
} else if (hoursRemaining > 12) {
colorClass = 'bg-orange-100 text-orange-700'
} else {
colorClass = 'bg-red-100 text-red-700'
}
return (
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-bold rounded-full ${colorClass}`}>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{hoursRemaining.toFixed(0)}h verbleibend
</span>
)
}
function Badge({ bgColor, color, label }: { bgColor: string; color: string; label: string }) {
return <span className={`px-2 py-1 text-xs rounded-full ${bgColor} ${color}`}>{label}</span>
}
function IncidentCard({ incident }: { incident: Incident }) {
const severityInfo = INCIDENT_SEVERITY_INFO[incident.severity]
const statusInfo = INCIDENT_STATUS_INFO[incident.status]
const categoryInfo = INCIDENT_CATEGORY_INFO[incident.category]
const expired = is72hDeadlineExpired(incident.detectedAt)
const isNotified = incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')
const severityBorderColors: Record<IncidentSeverity, string> = {
critical: 'border-red-300 hover:border-red-400',
high: 'border-orange-300 hover:border-orange-400',
medium: 'border-yellow-300 hover:border-yellow-400',
low: 'border-green-200 hover:border-green-300'
}
const borderColor = incident.status === 'closed'
? 'border-green-200 hover:border-green-300'
: expired && !isNotified
? 'border-red-400 hover:border-red-500'
: severityBorderColors[incident.severity]
const measuresCount = incident.measures.length
const completedMeasures = incident.measures.filter(m => m.status === 'completed').length
return (
<Link href={`/sdk/incidents/${incident.id}`}>
<div className={`
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
${borderColor}
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Header Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-xs text-gray-500 font-mono">
{incident.referenceNumber}
</span>
<Badge bgColor={severityInfo.bgColor} color={severityInfo.color} label={severityInfo.label} />
<Badge bgColor={categoryInfo.bgColor} color={categoryInfo.color} label={`${categoryInfo.icon} ${categoryInfo.label}`} />
<Badge bgColor={statusInfo.bgColor} color={statusInfo.color} label={statusInfo.label} />
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{incident.title}
</h3>
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{incident.description}
</p>
{/* 72h Countdown - prominent */}
<div className="mt-3">
<CountdownTimer incident={incident} />
</div>
</div>
{/* Right Side - Key Numbers */}
<div className="text-right ml-4 flex-shrink-0">
<div className="text-sm text-gray-500">
Betroffene
</div>
<div className="text-xl font-bold text-gray-900">
{incident.estimatedAffectedPersons.toLocaleString('de-DE')}
</div>
<div className="text-xs text-gray-400 mt-1">
{new Date(incident.detectedAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
{completedMeasures}/{measuresCount} Massnahmen
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{incident.timeline.length} Eintraege
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">
{incident.assignedTo
? `Zugewiesen: ${incident.assignedTo}`
: 'Nicht zugewiesen'
}
</span>
{incident.status !== 'closed' ? (
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Bearbeiten
</span>
) : (
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Details
</span>
)}
</div>
</div>
</div>
</Link>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function IncidentsPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [incidents, setIncidents] = useState<Incident[]>([])
const [statistics, setStatistics] = useState<IncidentStatistics | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Filters
const [selectedSeverity, setSelectedSeverity] = useState<IncidentSeverity | 'all'>('all')
const [selectedStatus, setSelectedStatus] = useState<IncidentStatus | 'all'>('all')
const [selectedCategory, setSelectedCategory] = useState<IncidentCategory | 'all'>('all')
// Load data
useEffect(() => {
const loadData = async () => {
setIsLoading(true)
try {
const { incidents: loadedIncidents, statistics: loadedStats } = await fetchSDKIncidentList()
setIncidents(loadedIncidents)
setStatistics(loadedStats)
} catch (error) {
console.error('Fehler beim Laden der Incident-Daten:', error)
// Fallback auf Mock-Daten
setIncidents(createMockIncidents())
setStatistics(createMockStatistics())
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
// Calculate tab counts
const tabCounts = useMemo(() => {
return {
active: incidents.filter(i =>
i.status === 'detected' || i.status === 'assessment' ||
i.status === 'containment' || i.status === 'remediation'
).length,
notification: incidents.filter(i =>
i.status === 'notification_required' || i.status === 'notification_sent' ||
(i.authorityNotification !== null && i.authorityNotification.status === 'pending')
).length,
closed: incidents.filter(i => i.status === 'closed').length,
deadlineExpired: incidents.filter(i => {
if (i.status === 'closed') return false
if (i.authorityNotification && (i.authorityNotification.status === 'submitted' || i.authorityNotification.status === 'acknowledged')) return false
if (i.riskAssessment && !i.riskAssessment.notificationRequired) return false
return is72hDeadlineExpired(i.detectedAt)
}).length,
deadlineApproaching: incidents.filter(i => {
if (i.status === 'closed') return false
if (i.authorityNotification && (i.authorityNotification.status === 'submitted' || i.authorityNotification.status === 'acknowledged')) return false
const hours = getHoursUntil72hDeadline(i.detectedAt)
return hours > 0 && hours <= 24
}).length
}
}, [incidents])
// Filter incidents based on active tab and filters
const filteredIncidents = useMemo(() => {
let filtered = [...incidents]
// Tab-based filtering
if (activeTab === 'active') {
filtered = filtered.filter(i =>
i.status === 'detected' || i.status === 'assessment' ||
i.status === 'containment' || i.status === 'remediation'
)
} else if (activeTab === 'notification') {
filtered = filtered.filter(i =>
i.status === 'notification_required' || i.status === 'notification_sent' ||
(i.authorityNotification !== null && i.authorityNotification.status === 'pending')
)
} else if (activeTab === 'closed') {
filtered = filtered.filter(i => i.status === 'closed')
}
// Severity filter
if (selectedSeverity !== 'all') {
filtered = filtered.filter(i => i.severity === selectedSeverity)
}
// Status filter
if (selectedStatus !== 'all') {
filtered = filtered.filter(i => i.status === selectedStatus)
}
// Category filter
if (selectedCategory !== 'all') {
filtered = filtered.filter(i => i.category === selectedCategory)
}
// Sort: most urgent first (overdue > deadline approaching > severity > detected time)
const severityOrder: Record<IncidentSeverity, number> = { critical: 0, high: 1, medium: 2, low: 3 }
return filtered.sort((a, b) => {
// Closed always at the end
if (a.status === 'closed' !== (b.status === 'closed')) return a.status === 'closed' ? 1 : -1
// Overdue first
const aExpired = is72hDeadlineExpired(a.detectedAt)
const bExpired = is72hDeadlineExpired(b.detectedAt)
if (aExpired !== bExpired) return aExpired ? -1 : 1
// Then by severity
if (severityOrder[a.severity] !== severityOrder[b.severity]) {
return severityOrder[a.severity] - severityOrder[b.severity]
}
// Then by deadline urgency
return getHoursUntil72hDeadline(a.detectedAt) - getHoursUntil72hDeadline(b.detectedAt)
})
}, [incidents, activeTab, selectedSeverity, selectedStatus, selectedCategory])
const tabs: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'active', label: 'Aktiv', count: tabCounts.active, countColor: 'bg-orange-100 text-orange-600' },
{ id: 'notification', label: 'Meldepflichtig', count: tabCounts.notification, countColor: 'bg-red-100 text-red-600' },
{ id: 'closed', label: 'Abgeschlossen', count: tabCounts.closed, countColor: 'bg-green-100 text-green-600' },
{ id: 'settings', label: 'Einstellungen' }
]
const stepInfo = STEP_EXPLANATIONS['incidents']
const clearFilters = () => {
setSelectedSeverity('all')
setSelectedStatus('all')
setSelectedCategory('all')
}
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="incidents"
title={stepInfo.title}
description={stepInfo.description}
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<Link
href="/sdk/incidents/new"
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Vorfall melden
</Link>
</StepHeader>
{/* Tab Navigation */}
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Loading State */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : activeTab === 'settings' ? (
/* Settings Tab */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
<p className="mt-2 text-gray-500">
Incident-Management-Einstellungen, Eskalationswege und Meldevorlagen
werden in einer spaeteren Version verfuegbar sein.
</p>
</div>
) : (
<>
{/* Statistics (Overview Tab) */}
{activeTab === 'overview' && statistics && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Gesamt Vorfaelle"
value={statistics.totalIncidents}
color="gray"
/>
<StatCard
label="Offene Vorfaelle"
value={statistics.openIncidents}
color="orange"
/>
<StatCard
label="Meldungen ausstehend"
value={statistics.notificationsPending}
color={statistics.notificationsPending > 0 ? 'red' : 'green'}
/>
<StatCard
label="Durchschn. Reaktionszeit"
value={`${statistics.averageResponseTimeHours}h`}
color="purple"
/>
</div>
)}
{/* Critical Alert: 72h deadline approaching or expired */}
{(tabCounts.deadlineExpired > 0 || tabCounts.deadlineApproaching > 0) && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<h4 className="font-medium text-red-800">
{tabCounts.deadlineExpired > 0
? `Achtung: ${tabCounts.deadlineExpired} ueberfaellige Meldung(en) - 72-Stunden-Frist ueberschritten!`
: `Warnung: ${tabCounts.deadlineApproaching} Meldung(en) mit ablaufender 72-Stunden-Frist`
}
</h4>
<p className="text-sm text-red-600">
{tabCounts.deadlineExpired > 0
? 'Die gesetzliche Meldefrist nach Art. 33 DSGVO ist abgelaufen. Handeln Sie umgehend, um Bussgelder zu vermeiden. Verspaetete Meldungen muessen begruendet werden.'
: 'Die 72-Stunden-Meldefrist nach Art. 33 DSGVO laeuft in Kuerze ab. Fuehren Sie eine Risikobewertung durch und entscheiden Sie ueber die Meldepflicht.'
}
</p>
</div>
<button
onClick={() => {
setActiveTab('active')
setSelectedStatus('all')
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Anzeigen
</button>
</div>
)}
{/* Info Box (Overview Tab) */}
{activeTab === 'overview' && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-medium text-blue-800">Art. 33/34 DSGVO - 72-Stunden-Meldepflicht</h4>
<p className="text-sm text-blue-600 mt-1">
Nach Art. 33 DSGVO muessen Datenschutzverletzungen innerhalb von 72 Stunden
an die zustaendige Aufsichtsbehoerde gemeldet werden, sofern ein Risiko fuer
die Rechte und Freiheiten der betroffenen Personen besteht. Bei hohem Risiko
muessen gemaess Art. 34 DSGVO auch die betroffenen Personen benachrichtigt werden.
Alle Vorfaelle sind unabhaengig von der Meldepflicht zu dokumentieren (Art. 33 Abs. 5).
</p>
</div>
</div>
</div>
)}
{/* Filters */}
<FilterBar
selectedSeverity={selectedSeverity}
selectedStatus={selectedStatus}
selectedCategory={selectedCategory}
onSeverityChange={setSelectedSeverity}
onStatusChange={setSelectedStatus}
onCategoryChange={setSelectedCategory}
onClear={clearFilters}
/>
{/* Incidents List */}
<div className="space-y-4">
{filteredIncidents.map(incident => (
<IncidentCard key={incident.id} incident={incident} />
))}
</div>
{/* Empty State */}
{filteredIncidents.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Vorfaelle gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all'
? 'Passen Sie die Filter an oder'
: 'Es sind noch keine Vorfaelle erfasst worden.'
}
</p>
{(selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all') ? (
<button
onClick={clearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
) : (
<Link
href="/sdk/incidents/new"
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Ersten Vorfall erfassen
</Link>
)}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,879 @@
'use client'
/**
* Branchenspezifische Module (Phase 3.3)
*
* Industry-specific compliance template packages:
* - Browse industry templates (grid view)
* - View full detail with VVT, TOM, Risk tabs
* - Apply template packages to current compliance setup
*/
import { useState, useEffect, useCallback } from 'react'
// =============================================================================
// TYPES
// =============================================================================
interface IndustrySummary {
slug: string
name: string
description: string
icon: string
regulation_count: number
template_count: number
}
interface IndustryTemplate {
slug: string
name: string
description: string
icon: string
regulations: string[]
vvt_templates: VVTTemplate[]
tom_recommendations: TOMRecommendation[]
risk_scenarios: RiskScenario[]
}
interface VVTTemplate {
name: string
purpose: string
legal_basis: string
data_categories: string[]
data_subjects: string[]
retention_period: string
}
interface TOMRecommendation {
category: string
name: string
description: string
priority: string
}
interface RiskScenario {
name: string
description: string
likelihood: string
impact: string
mitigation: string
}
// =============================================================================
// CONSTANTS
// =============================================================================
type DetailTab = 'vvt' | 'tom' | 'risks'
const DETAIL_TABS: { key: DetailTab; label: string }[] = [
{ key: 'vvt', label: 'VVT-Vorlagen' },
{ key: 'tom', label: 'TOM-Empfehlungen' },
{ key: 'risks', label: 'Risiko-Szenarien' },
]
const PRIORITY_COLORS: Record<string, { bg: string; text: string; border: string }> = {
critical: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200' },
high: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' },
medium: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200' },
low: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200' },
}
const PRIORITY_LABELS: Record<string, string> = {
critical: 'Kritisch',
high: 'Hoch',
medium: 'Mittel',
low: 'Niedrig',
}
const LIKELIHOOD_COLORS: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
}
const IMPACT_COLORS: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
critical: 'bg-red-600',
}
const TOM_CATEGORY_ICONS: Record<string, string> = {
'Zutrittskontrolle': '\uD83D\uDEAA',
'Zugangskontrolle': '\uD83D\uDD10',
'Zugriffskontrolle': '\uD83D\uDC65',
'Trennungskontrolle': '\uD83D\uDDC2\uFE0F',
'Pseudonymisierung': '\uD83C\uDFAD',
'Verschluesselung': '\uD83D\uDD12',
'Integritaet': '\u2705',
'Verfuegbarkeit': '\u2B06\uFE0F',
'Belastbarkeit': '\uD83D\uDEE1\uFE0F',
'Wiederherstellung': '\uD83D\uDD04',
'Datenschutz-Management': '\uD83D\uDCCB',
'Auftragsverarbeitung': '\uD83D\uDCDD',
'Incident Response': '\uD83D\uDEA8',
'Schulung': '\uD83C\uDF93',
'Netzwerksicherheit': '\uD83C\uDF10',
'Datensicherung': '\uD83D\uDCBE',
'Monitoring': '\uD83D\uDCCA',
'Physische Sicherheit': '\uD83C\uDFE2',
}
// =============================================================================
// SKELETON COMPONENTS
// =============================================================================
function GridSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white rounded-xl border border-slate-200 p-6 animate-pulse">
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-xl bg-slate-200" />
<div className="flex-1 space-y-3">
<div className="h-5 bg-slate-200 rounded w-2/3" />
<div className="h-4 bg-slate-100 rounded w-full" />
<div className="h-4 bg-slate-100 rounded w-4/5" />
</div>
</div>
<div className="flex gap-3 mt-5">
<div className="h-6 bg-slate-100 rounded-full w-28" />
<div className="h-6 bg-slate-100 rounded-full w-24" />
</div>
</div>
))}
</div>
)
}
function DetailSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="bg-white rounded-xl border p-6 space-y-4">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-xl bg-slate-200" />
<div className="space-y-2 flex-1">
<div className="h-6 bg-slate-200 rounded w-1/3" />
<div className="h-4 bg-slate-100 rounded w-2/3" />
</div>
</div>
<div className="flex gap-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-7 bg-slate-100 rounded-full w-20" />
))}
</div>
</div>
<div className="bg-white rounded-xl border p-6 space-y-4">
<div className="flex gap-2 border-b pb-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-9 bg-slate-100 rounded-lg w-32" />
))}
</div>
{[1, 2, 3].map((i) => (
<div key={i} className="h-28 bg-slate-50 rounded-lg" />
))}
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE COMPONENT
// =============================================================================
export default function IndustryTemplatesPage() {
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const [industries, setIndustries] = useState<IndustrySummary[]>([])
const [selectedDetail, setSelectedDetail] = useState<IndustryTemplate | null>(null)
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<DetailTab>('vvt')
const [loading, setLoading] = useState(true)
const [detailLoading, setDetailLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [detailError, setDetailError] = useState<string | null>(null)
const [applying, setApplying] = useState(false)
const [toastMessage, setToastMessage] = useState<string | null>(null)
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
const loadIndustries = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/sdk/v1/industry/templates')
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
const data = await res.json()
setIndustries(Array.isArray(data) ? data : data.industries || data.templates || [])
} catch (err) {
console.error('Failed to load industries:', err)
setError('Branchenvorlagen konnten nicht geladen werden. Bitte pruefen Sie die Verbindung zum Backend.')
} finally {
setLoading(false)
}
}, [])
const loadDetail = useCallback(async (slug: string) => {
setDetailLoading(true)
setDetailError(null)
setSelectedSlug(slug)
setActiveTab('vvt')
try {
const [detailRes, vvtRes, tomRes, risksRes] = await Promise.all([
fetch(`/api/sdk/v1/industry/templates/${slug}`),
fetch(`/api/sdk/v1/industry/templates/${slug}/vvt`),
fetch(`/api/sdk/v1/industry/templates/${slug}/tom`),
fetch(`/api/sdk/v1/industry/templates/${slug}/risks`),
])
if (!detailRes.ok) {
throw new Error(`HTTP ${detailRes.status}: ${detailRes.statusText}`)
}
const detail: IndustryTemplate = await detailRes.json()
// Merge sub-resources if the detail endpoint did not include them
if (vvtRes.ok) {
const vvtData = await vvtRes.json()
detail.vvt_templates = vvtData.vvt_templates || vvtData.templates || vvtData || []
}
if (tomRes.ok) {
const tomData = await tomRes.json()
detail.tom_recommendations = tomData.tom_recommendations || tomData.recommendations || tomData || []
}
if (risksRes.ok) {
const risksData = await risksRes.json()
detail.risk_scenarios = risksData.risk_scenarios || risksData.scenarios || risksData || []
}
setSelectedDetail(detail)
} catch (err) {
console.error('Failed to load industry detail:', err)
setDetailError('Details konnten nicht geladen werden. Bitte versuchen Sie es erneut.')
} finally {
setDetailLoading(false)
}
}, [])
useEffect(() => {
loadIndustries()
}, [loadIndustries])
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const handleBackToGrid = useCallback(() => {
setSelectedSlug(null)
setSelectedDetail(null)
setDetailError(null)
}, [])
const handleApplyPackage = useCallback(async () => {
if (!selectedDetail) return
setApplying(true)
try {
// Placeholder: In production this would POST to an import endpoint
await new Promise((resolve) => setTimeout(resolve, 1500))
setToastMessage(
`Branchenpaket "${selectedDetail.name}" wurde erfolgreich angewendet. ` +
`${selectedDetail.vvt_templates?.length || 0} VVT-Vorlagen, ` +
`${selectedDetail.tom_recommendations?.length || 0} TOM-Empfehlungen und ` +
`${selectedDetail.risk_scenarios?.length || 0} Risiko-Szenarien wurden importiert.`
)
} catch {
setToastMessage('Fehler beim Anwenden des Branchenpakets. Bitte versuchen Sie es erneut.')
} finally {
setApplying(false)
}
}, [selectedDetail])
// Auto-dismiss toast
useEffect(() => {
if (!toastMessage) return
const timer = setTimeout(() => setToastMessage(null), 6000)
return () => clearTimeout(timer)
}, [toastMessage])
// ---------------------------------------------------------------------------
// Render: Header
// ---------------------------------------------------------------------------
const renderHeader = () => (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white text-lg">
{'\uD83C\uDFED'}
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">Branchenspezifische Module</h1>
<p className="text-slate-500 mt-0.5">
Vorkonfigurierte Compliance-Pakete nach Branche
</p>
</div>
</div>
</div>
)
// ---------------------------------------------------------------------------
// Render: Error
// ---------------------------------------------------------------------------
const renderError = (message: string, onRetry: () => void) => (
<div className="bg-red-50 border border-red-200 rounded-xl p-5 flex items-start gap-3">
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="flex-1">
<p className="text-red-700 font-medium">Fehler</p>
<p className="text-red-600 text-sm mt-1">{message}</p>
</div>
<button
onClick={onRetry}
className="px-4 py-1.5 text-sm font-medium text-red-700 bg-red-100 hover:bg-red-200 rounded-lg transition-colors"
>
Erneut versuchen
</button>
</div>
)
// ---------------------------------------------------------------------------
// Render: Industry Grid
// ---------------------------------------------------------------------------
const renderGrid = () => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{industries.map((industry) => (
<button
key={industry.slug}
onClick={() => loadDetail(industry.slug)}
className="bg-white rounded-xl border border-slate-200 p-6 text-left hover:border-emerald-300 hover:shadow-md transition-all duration-200 group"
>
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-100 flex items-center justify-center text-3xl flex-shrink-0 group-hover:from-emerald-100 group-hover:to-teal-100 transition-colors">
{industry.icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-slate-900 group-hover:text-emerald-700 transition-colors">
{industry.name}
</h3>
<p className="text-sm text-slate-500 mt-1 line-clamp-2">
{industry.description}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-4">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-100">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{industry.regulation_count} Regulierungen
</span>
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-teal-50 text-teal-700 border border-teal-100">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
{industry.template_count} Vorlagen
</span>
</div>
</button>
))}
</div>
)
// ---------------------------------------------------------------------------
// Render: Detail View - Header
// ---------------------------------------------------------------------------
const renderDetailHeader = () => {
if (!selectedDetail) return null
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<button
onClick={handleBackToGrid}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-emerald-600 transition-colors mb-4"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck zur Uebersicht
</button>
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-100 flex items-center justify-center text-4xl flex-shrink-0">
{selectedDetail.icon}
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-slate-900">{selectedDetail.name}</h2>
<p className="text-slate-500 mt-1">{selectedDetail.description}</p>
</div>
</div>
{/* Regulation Badges */}
{selectedDetail.regulations && selectedDetail.regulations.length > 0 && (
<div className="mt-4">
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-2">
Relevante Regulierungen
</p>
<div className="flex flex-wrap gap-2">
{selectedDetail.regulations.map((reg) => (
<span
key={reg}
className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-200"
>
{reg}
</span>
))}
</div>
</div>
)}
{/* Summary stats */}
<div className="grid grid-cols-3 gap-4 mt-5 pt-5 border-t border-slate-100">
<div className="text-center">
<p className="text-2xl font-bold text-emerald-600">
{selectedDetail.vvt_templates?.length || 0}
</p>
<p className="text-xs text-slate-500 mt-0.5">VVT-Vorlagen</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-teal-600">
{selectedDetail.tom_recommendations?.length || 0}
</p>
<p className="text-xs text-slate-500 mt-0.5">TOM-Empfehlungen</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-amber-600">
{selectedDetail.risk_scenarios?.length || 0}
</p>
<p className="text-xs text-slate-500 mt-0.5">Risiko-Szenarien</p>
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Render: VVT Tab
// ---------------------------------------------------------------------------
const renderVVTTab = () => {
const templates = selectedDetail?.vvt_templates || []
if (templates.length === 0) {
return (
<div className="text-center py-12 text-slate-400">
<p className="text-lg">Keine VVT-Vorlagen verfuegbar</p>
<p className="text-sm mt-1">Fuer diese Branche wurden noch keine Verarbeitungsvorlagen definiert.</p>
</div>
)
}
return (
<div className="space-y-4">
{templates.map((vvt, idx) => (
<div
key={idx}
className="bg-white border border-slate-200 rounded-lg p-5 hover:border-emerald-200 transition-colors"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h4 className="font-semibold text-slate-900">{vvt.name}</h4>
<p className="text-sm text-slate-500 mt-1">{vvt.purpose}</p>
</div>
<span className="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-slate-100 text-slate-600 flex-shrink-0">
{vvt.retention_period}
</span>
</div>
<div className="mt-3 pt-3 border-t border-slate-100">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Legal Basis */}
<div>
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">Rechtsgrundlage</p>
<p className="text-sm text-slate-700">{vvt.legal_basis}</p>
</div>
{/* Retention Period (mobile only, since shown in badge on desktop) */}
<div className="sm:hidden">
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">Aufbewahrungsfrist</p>
<p className="text-sm text-slate-700">{vvt.retention_period}</p>
</div>
{/* Data Categories */}
<div>
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">Datenkategorien</p>
<div className="flex flex-wrap gap-1.5">
{vvt.data_categories.map((cat) => (
<span
key={cat}
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-emerald-50 text-emerald-700 border border-emerald-100"
>
{cat}
</span>
))}
</div>
</div>
{/* Data Subjects */}
<div>
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">Betroffene</p>
<div className="flex flex-wrap gap-1.5">
{vvt.data_subjects.map((sub) => (
<span
key={sub}
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-teal-50 text-teal-700 border border-teal-100"
>
{sub}
</span>
))}
</div>
</div>
</div>
</div>
</div>
))}
</div>
)
}
// ---------------------------------------------------------------------------
// Render: TOM Tab
// ---------------------------------------------------------------------------
const renderTOMTab = () => {
const recommendations = selectedDetail?.tom_recommendations || []
if (recommendations.length === 0) {
return (
<div className="text-center py-12 text-slate-400">
<p className="text-lg">Keine TOM-Empfehlungen verfuegbar</p>
<p className="text-sm mt-1">Fuer diese Branche wurden noch keine technisch-organisatorischen Massnahmen definiert.</p>
</div>
)
}
// Group by category
const grouped: Record<string, TOMRecommendation[]> = {}
recommendations.forEach((tom) => {
if (!grouped[tom.category]) {
grouped[tom.category] = []
}
grouped[tom.category].push(tom)
})
return (
<div className="space-y-6">
{Object.entries(grouped).map(([category, items]) => {
const icon = TOM_CATEGORY_ICONS[category] || '\uD83D\uDD27'
return (
<div key={category}>
<div className="flex items-center gap-2 mb-3">
<span className="text-lg">{icon}</span>
<h4 className="font-semibold text-slate-800">{category}</h4>
<span className="text-xs text-slate-400 ml-1">({items.length})</span>
</div>
<div className="space-y-3 ml-7">
{items.map((tom, idx) => {
const prio = PRIORITY_COLORS[tom.priority] || PRIORITY_COLORS.medium
const prioLabel = PRIORITY_LABELS[tom.priority] || tom.priority
return (
<div
key={idx}
className="bg-white border border-slate-200 rounded-lg p-4 hover:border-emerald-200 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<h5 className="font-medium text-slate-900">{tom.name}</h5>
<p className="text-sm text-slate-500 mt-1">{tom.description}</p>
</div>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border flex-shrink-0 ${prio.bg} ${prio.text} ${prio.border}`}
>
{prioLabel}
</span>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
)
}
// ---------------------------------------------------------------------------
// Render: Risk Tab
// ---------------------------------------------------------------------------
const renderRiskTab = () => {
const scenarios = selectedDetail?.risk_scenarios || []
if (scenarios.length === 0) {
return (
<div className="text-center py-12 text-slate-400">
<p className="text-lg">Keine Risiko-Szenarien verfuegbar</p>
<p className="text-sm mt-1">Fuer diese Branche wurden noch keine Risiko-Szenarien definiert.</p>
</div>
)
}
return (
<div className="space-y-4">
{scenarios.map((risk, idx) => {
const likelihoodColor = LIKELIHOOD_COLORS[risk.likelihood] || 'bg-slate-400'
const impactColor = IMPACT_COLORS[risk.impact] || 'bg-slate-400'
const likelihoodLabel = PRIORITY_LABELS[risk.likelihood] || risk.likelihood
const impactLabel = PRIORITY_LABELS[risk.impact] || risk.impact
return (
<div
key={idx}
className="bg-white border border-slate-200 rounded-lg p-5 hover:border-emerald-200 transition-colors"
>
<div className="flex items-start justify-between gap-4">
<h4 className="font-semibold text-slate-900">{risk.name}</h4>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Likelihood badge */}
<div className="flex items-center gap-1.5">
<span className={`w-2.5 h-2.5 rounded-full ${likelihoodColor}`} />
<span className="text-xs text-slate-500">
Wahrsch.: <span className="font-medium text-slate-700">{likelihoodLabel}</span>
</span>
</div>
<span className="text-slate-300">|</span>
{/* Impact badge */}
<div className="flex items-center gap-1.5">
<span className={`w-2.5 h-2.5 rounded-full ${impactColor}`} />
<span className="text-xs text-slate-500">
Auswirkung: <span className="font-medium text-slate-700">{impactLabel}</span>
</span>
</div>
</div>
</div>
<p className="text-sm text-slate-500 mt-2">{risk.description}</p>
{/* Mitigation */}
<div className="mt-3 pt-3 border-t border-slate-100">
<div className="flex items-start gap-2">
<svg className="w-4 h-4 text-emerald-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<div>
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">Massnahme</p>
<p className="text-sm text-slate-700 mt-0.5">{risk.mitigation}</p>
</div>
</div>
</div>
</div>
)
})}
</div>
)
}
// ---------------------------------------------------------------------------
// Render: Detail Tabs + Content
// ---------------------------------------------------------------------------
const renderDetailContent = () => {
if (!selectedDetail) return null
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{/* Tab Navigation */}
<div className="flex border-b border-slate-200 bg-slate-50">
{DETAIL_TABS.map((tab) => {
const isActive = activeTab === tab.key
let count = 0
if (tab.key === 'vvt') count = selectedDetail.vvt_templates?.length || 0
if (tab.key === 'tom') count = selectedDetail.tom_recommendations?.length || 0
if (tab.key === 'risks') count = selectedDetail.risk_scenarios?.length || 0
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${
isActive
? 'text-emerald-700 bg-white border-b-2 border-emerald-500'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-100'
}`}
>
{tab.label}
{count > 0 && (
<span
className={`ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 rounded-full text-xs ${
isActive
? 'bg-emerald-100 text-emerald-700'
: 'bg-slate-200 text-slate-500'
}`}
>
{count}
</span>
)}
</button>
)
})}
</div>
{/* Tab Content */}
<div className="p-6">
{activeTab === 'vvt' && renderVVTTab()}
{activeTab === 'tom' && renderTOMTab()}
{activeTab === 'risks' && renderRiskTab()}
</div>
{/* Apply Button */}
<div className="px-6 py-4 border-t border-slate-200 bg-slate-50">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">
Importiert alle Vorlagen, Empfehlungen und Szenarien in Ihr System.
</p>
<button
onClick={handleApplyPackage}
disabled={applying}
className={`inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-semibold text-white transition-all ${
applying
? 'bg-emerald-400 cursor-not-allowed'
: 'bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 shadow-sm hover:shadow-md'
}`}
>
{applying ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Wird angewendet...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Branchenpaket anwenden
</>
)}
</button>
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Render: Toast
// ---------------------------------------------------------------------------
const renderToast = () => {
if (!toastMessage) return null
return (
<div className="fixed bottom-6 right-6 z-50 max-w-md animate-slide-up">
<div className="bg-slate-900 text-white rounded-xl shadow-2xl px-5 py-4 flex items-start gap-3">
<svg className="w-5 h-5 text-emerald-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm leading-relaxed">{toastMessage}</p>
<button
onClick={() => setToastMessage(null)}
className="text-slate-400 hover:text-white flex-shrink-0 ml-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Render: Empty state
// ---------------------------------------------------------------------------
const renderEmptyState = () => (
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
<div className="w-16 h-16 rounded-2xl bg-emerald-50 flex items-center justify-center text-3xl mx-auto mb-4">
{'\uD83C\uDFED'}
</div>
<h3 className="text-lg font-semibold text-slate-900">Keine Branchenvorlagen verfuegbar</h3>
<p className="text-slate-500 mt-2 max-w-md mx-auto">
Es sind derzeit keine branchenspezifischen Compliance-Pakete im System hinterlegt.
Bitte kontaktieren Sie den Administrator oder versuchen Sie es spaeter erneut.
</p>
<button
onClick={loadIndustries}
className="mt-4 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 rounded-lg transition-colors"
>
Erneut laden
</button>
</div>
)
// ---------------------------------------------------------------------------
// Main Render
// ---------------------------------------------------------------------------
return (
<div className="space-y-6">
{/* Inline keyframe for toast animation */}
<style>{`
@keyframes slide-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
`}</style>
{renderHeader()}
{/* Error state */}
{error && renderError(error, loadIndustries)}
{/* Main Content */}
{loading ? (
selectedSlug ? <DetailSkeleton /> : <GridSkeleton />
) : selectedSlug ? (
// Detail View
<div className="space-y-6">
{detailLoading ? (
<DetailSkeleton />
) : detailError ? (
<>
<button
onClick={handleBackToGrid}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-emerald-600 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck zur Uebersicht
</button>
{renderError(detailError, () => loadDetail(selectedSlug))}
</>
) : (
<>
{renderDetailHeader()}
{renderDetailContent()}
</>
)}
</div>
) : industries.length === 0 && !error ? (
renderEmptyState()
) : (
renderGrid()
)}
{/* Toast notification */}
{renderToast()}
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,10 @@
import { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
import StepHeader from '@/components/sdk/StepHeader/StepHeader'
import { SourcesTab } from '@/app/(admin)/compliance/source-policy/components/SourcesTab'
import { OperationsMatrixTab } from '@/app/(admin)/compliance/source-policy/components/OperationsMatrixTab'
import { PIIRulesTab } from '@/app/(admin)/compliance/source-policy/components/PIIRulesTab'
import { AuditTab } from '@/app/(admin)/compliance/source-policy/components/AuditTab'
import { SourcesTab } from '@/components/sdk/source-policy/SourcesTab'
import { OperationsMatrixTab } from '@/components/sdk/source-policy/OperationsMatrixTab'
import { PIIRulesTab } from '@/components/sdk/source-policy/PIIRulesTab'
import { AuditTab } from '@/components/sdk/source-policy/AuditTab'
// API base URL for edu-search-service
const getApiBase = () => {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,669 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
WhistleblowerReport,
WhistleblowerStatistics,
ReportCategory,
ReportStatus,
ReportPriority,
REPORT_CATEGORY_INFO,
REPORT_STATUS_INFO,
isAcknowledgmentOverdue,
isFeedbackOverdue,
getDaysUntilAcknowledgment,
getDaysUntilFeedback
} from '@/lib/sdk/whistleblower/types'
import { fetchSDKWhistleblowerList } from '@/lib/sdk/whistleblower/api'
// =============================================================================
// TYPES
// =============================================================================
type TabId = 'overview' | 'new_reports' | 'investigation' | 'closed' | 'settings'
interface Tab {
id: TabId
label: string
count?: number
countColor?: string
}
// =============================================================================
// COMPONENTS
// =============================================================================
function TabNavigation({
tabs,
activeTab,
onTabChange
}: {
tabs: Tab[]
activeTab: TabId
onTabChange: (tab: TabId) => void
}) {
return (
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span className="flex items-center gap-2">
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className={`
px-2 py-0.5 text-xs rounded-full
${tab.countColor || 'bg-gray-100 text-gray-600'}
`}>
{tab.count}
</span>
)}
</span>
</button>
))}
</nav>
</div>
)
}
function StatCard({
label,
value,
color = 'gray',
icon,
trend
}: {
label: string
value: number | string
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
icon?: React.ReactNode
trend?: { value: number; label: string }
}) {
const colorClasses = {
gray: 'border-gray-200 text-gray-900',
blue: 'border-blue-200 text-blue-600',
yellow: 'border-yellow-200 text-yellow-600',
red: 'border-red-200 text-red-600',
green: 'border-green-200 text-green-600',
purple: 'border-purple-200 text-purple-600'
}
return (
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
<div className="flex items-start justify-between">
<div>
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : `text-${color}-600`}`}>
{label}
</div>
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
{value}
</div>
{trend && (
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
</div>
)}
</div>
{icon && (
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}>
{icon}
</div>
)}
</div>
</div>
)
}
function FilterBar({
selectedCategory,
selectedStatus,
selectedPriority,
onCategoryChange,
onStatusChange,
onPriorityChange,
onClear
}: {
selectedCategory: ReportCategory | 'all'
selectedStatus: ReportStatus | 'all'
selectedPriority: ReportPriority | 'all'
onCategoryChange: (category: ReportCategory | 'all') => void
onStatusChange: (status: ReportStatus | 'all') => void
onPriorityChange: (priority: ReportPriority | 'all') => void
onClear: () => void
}) {
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
return (
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{/* Category Filter */}
<select
value={selectedCategory}
onChange={(e) => onCategoryChange(e.target.value as ReportCategory | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Kategorien</option>
{Object.entries(REPORT_CATEGORY_INFO).map(([key, info]) => (
<option key={key} value={key}>{info.label}</option>
))}
</select>
{/* Status Filter */}
<select
value={selectedStatus}
onChange={(e) => onStatusChange(e.target.value as ReportStatus | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Status</option>
{Object.entries(REPORT_STATUS_INFO).map(([status, info]) => (
<option key={status} value={status}>{info.label}</option>
))}
</select>
{/* Priority Filter */}
<select
value={selectedPriority}
onChange={(e) => onPriorityChange(e.target.value as ReportPriority | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Prioritaeten</option>
<option value="critical">Kritisch</option>
<option value="high">Hoch</option>
<option value="normal">Normal</option>
<option value="low">Niedrig</option>
</select>
{/* Clear Filters */}
{hasFilters && (
<button
onClick={onClear}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)
}
function ReportCard({ report }: { report: WhistleblowerReport }) {
const categoryInfo = REPORT_CATEGORY_INFO[report.category]
const statusInfo = REPORT_STATUS_INFO[report.status]
const isClosed = report.status === 'closed' || report.status === 'rejected'
const ackOverdue = isAcknowledgmentOverdue(report)
const fbOverdue = isFeedbackOverdue(report)
const daysAck = getDaysUntilAcknowledgment(report)
const daysFb = getDaysUntilFeedback(report)
const completedMeasures = report.measures.filter(m => m.status === 'completed').length
const totalMeasures = report.measures.length
const priorityLabels: Record<ReportPriority, string> = {
low: 'Niedrig',
normal: 'Normal',
high: 'Hoch',
critical: 'Kritisch'
}
return (
<div className={`
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
${ackOverdue || fbOverdue ? 'border-red-300 hover:border-red-400' :
report.priority === 'critical' ? 'border-orange-300 hover:border-orange-400' :
isClosed ? 'border-green-200 hover:border-green-300' :
'border-gray-200 hover:border-purple-300'
}
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Header Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-xs text-gray-500 font-mono">
{report.referenceNumber}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
{categoryInfo.label}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
{statusInfo.label}
</span>
{report.isAnonymous && (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Anonym
</span>
)}
{report.priority === 'critical' && (
<span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Kritisch
</span>
)}
{report.priority === 'high' && (
<span className="px-2 py-1 text-xs bg-orange-100 text-orange-700 rounded-full">
Hoch
</span>
)}
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{report.title}
</h3>
{/* Description Preview */}
{report.description && (
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{report.description}
</p>
)}
{/* Deadline Info */}
{!isClosed && (
<div className="flex items-center gap-4 mt-3 text-xs">
{report.status === 'new' && (
<span className={`flex items-center gap-1 ${ackOverdue ? 'text-red-600 font-medium' : daysAck <= 2 ? 'text-orange-600' : 'text-gray-500'}`}>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{ackOverdue
? `Bestaetigung ${Math.abs(daysAck)} Tage ueberfaellig`
: `Bestaetigung in ${daysAck} Tagen`
}
</span>
)}
<span className={`flex items-center gap-1 ${fbOverdue ? 'text-red-600 font-medium' : daysFb <= 14 ? 'text-orange-600' : 'text-gray-500'}`}>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{fbOverdue
? `Rueckmeldung ${Math.abs(daysFb)} Tage ueberfaellig`
: `Rueckmeldung in ${daysFb} Tagen`
}
</span>
</div>
)}
</div>
{/* Right Side - Date & Priority */}
<div className={`text-right ml-4 ${
ackOverdue || fbOverdue ? 'text-red-600' :
report.priority === 'critical' ? 'text-orange-600' :
'text-gray-500'
}`}>
<div className="text-sm font-medium">
{isClosed
? statusInfo.label
: ackOverdue
? 'Ueberfaellig'
: priorityLabels[report.priority]
}
</div>
<div className="text-xs mt-0.5">
{new Date(report.receivedAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-gray-500">
{report.assignedTo
? `Zugewiesen: ${report.assignedTo}`
: 'Nicht zugewiesen'
}
</div>
{report.attachments.length > 0 && (
<span className="flex items-center gap-1 text-xs text-gray-400">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
{report.attachments.length} Anhang{report.attachments.length !== 1 ? 'e' : ''}
</span>
)}
{totalMeasures > 0 && (
<span className={`flex items-center gap-1 text-xs ${completedMeasures === totalMeasures ? 'text-green-600' : 'text-yellow-600'}`}>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
{completedMeasures}/{totalMeasures} Massnahmen
</span>
)}
{report.messages.length > 0 && (
<span className="flex items-center gap-1 text-xs text-gray-400">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{report.messages.length} Nachricht{report.messages.length !== 1 ? 'en' : ''}
</span>
)}
</div>
<div className="flex items-center gap-2">
{!isClosed && (
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Bearbeiten
</span>
)}
{isClosed && (
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Details
</span>
)}
</div>
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function WhistleblowerPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [reports, setReports] = useState<WhistleblowerReport[]>([])
const [statistics, setStatistics] = useState<WhistleblowerStatistics | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Filters
const [selectedCategory, setSelectedCategory] = useState<ReportCategory | 'all'>('all')
const [selectedStatus, setSelectedStatus] = useState<ReportStatus | 'all'>('all')
const [selectedPriority, setSelectedPriority] = useState<ReportPriority | 'all'>('all')
// Load data from SDK backend
useEffect(() => {
const loadData = async () => {
setIsLoading(true)
try {
const { reports: wbReports, statistics: wbStats } = await fetchSDKWhistleblowerList()
setReports(wbReports)
setStatistics(wbStats)
} catch (error) {
console.error('Failed to load Whistleblower data:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
// Locally computed overdue counts (always fresh)
const overdueCounts = useMemo(() => {
const overdueAck = reports.filter(r => isAcknowledgmentOverdue(r)).length
const overdueFb = reports.filter(r => isFeedbackOverdue(r)).length
return { overdueAck, overdueFb }
}, [reports])
// Calculate tab counts
const tabCounts = useMemo(() => {
const investigationStatuses: ReportStatus[] = ['acknowledged', 'under_review', 'investigation', 'measures_taken']
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
return {
new_reports: reports.filter(r => r.status === 'new').length,
investigation: reports.filter(r => investigationStatuses.includes(r.status)).length,
closed: reports.filter(r => closedStatuses.includes(r.status)).length
}
}, [reports])
// Filter reports based on active tab and filters
const filteredReports = useMemo(() => {
let filtered = [...reports]
// Tab-based filtering
const investigationStatuses: ReportStatus[] = ['acknowledged', 'under_review', 'investigation', 'measures_taken']
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
if (activeTab === 'new_reports') {
filtered = filtered.filter(r => r.status === 'new')
} else if (activeTab === 'investigation') {
filtered = filtered.filter(r => investigationStatuses.includes(r.status))
} else if (activeTab === 'closed') {
filtered = filtered.filter(r => closedStatuses.includes(r.status))
}
// Category filter
if (selectedCategory !== 'all') {
filtered = filtered.filter(r => r.category === selectedCategory)
}
// Status filter
if (selectedStatus !== 'all') {
filtered = filtered.filter(r => r.status === selectedStatus)
}
// Priority filter
if (selectedPriority !== 'all') {
filtered = filtered.filter(r => r.priority === selectedPriority)
}
// Sort: overdue first, then by priority, then by date
return filtered.sort((a, b) => {
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
const getUrgency = (r: WhistleblowerReport) => {
if (closedStatuses.includes(r.status)) return 1000
const ackOd = isAcknowledgmentOverdue(r)
const fbOd = isFeedbackOverdue(r)
if (ackOd || fbOd) return -100
const priorityScore = { critical: 0, high: 1, normal: 2, low: 3 }
return priorityScore[r.priority] ?? 2
}
const urgencyDiff = getUrgency(a) - getUrgency(b)
if (urgencyDiff !== 0) return urgencyDiff
return new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
})
}, [reports, activeTab, selectedCategory, selectedStatus, selectedPriority])
const tabs: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'new_reports', label: 'Neue Meldungen', count: tabCounts.new_reports, countColor: 'bg-blue-100 text-blue-600' },
{ id: 'investigation', label: 'In Untersuchung', count: tabCounts.investigation, countColor: 'bg-yellow-100 text-yellow-600' },
{ id: 'closed', label: 'Abgeschlossen', count: tabCounts.closed, countColor: 'bg-green-100 text-green-600' },
{ id: 'settings', label: 'Einstellungen' }
]
const stepInfo = STEP_EXPLANATIONS['whistleblower']
const clearFilters = () => {
setSelectedCategory('all')
setSelectedStatus('all')
setSelectedPriority('all')
}
return (
<div className="space-y-6">
{/* Step Header - NO "create report" button (reports come from the public form) */}
<StepHeader
stepId="whistleblower"
title={stepInfo.title}
description={stepInfo.description}
explanation={stepInfo.explanation}
tips={stepInfo.tips}
/>
{/* Tab Navigation */}
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Loading State */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : activeTab === 'settings' ? (
/* Settings Tab */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
<p className="mt-2 text-gray-500">
Hinweisgebersystem-Einstellungen, Meldekanal-Konfiguration, Ombudsperson-Verwaltung
und E-Mail-Vorlagen werden in einer spaeteren Version verfuegbar sein.
</p>
</div>
) : (
<>
{/* Statistics (Overview Tab) */}
{activeTab === 'overview' && statistics && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Gesamt Meldungen"
value={statistics.totalReports}
color="gray"
/>
<StatCard
label="Neue Meldungen"
value={statistics.newReports}
color="blue"
/>
<StatCard
label="In Untersuchung"
value={statistics.underReview}
color="yellow"
/>
<StatCard
label="Ueberfaellige Bestaetigung"
value={overdueCounts.overdueAck}
color={overdueCounts.overdueAck > 0 ? 'red' : 'green'}
/>
</div>
)}
{/* Overdue Alert for Acknowledgment Deadline (7 days HinSchG) */}
{(overdueCounts.overdueAck > 0 || overdueCounts.overdueFb > 0) && (activeTab === 'overview' || activeTab === 'new_reports' || activeTab === 'investigation') && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<h4 className="font-medium text-red-800">
Achtung: Gesetzliche Fristen ueberschritten
</h4>
<p className="text-sm text-red-600 mt-0.5">
{overdueCounts.overdueAck > 0 && (
<span>{overdueCounts.overdueAck} Meldung(en) ohne Eingangsbestaetigung (mehr als 7 Tage, HinSchG ss 17 Abs. 1). </span>
)}
{overdueCounts.overdueFb > 0 && (
<span>{overdueCounts.overdueFb} Meldung(en) ohne Rueckmeldung (mehr als 3 Monate, HinSchG ss 17 Abs. 2). </span>
)}
Handeln Sie umgehend, um Bussgelder und Haftungsrisiken zu vermeiden.
</p>
</div>
<button
onClick={() => {
if (overdueCounts.overdueAck > 0) {
setActiveTab('new_reports')
} else {
setActiveTab('investigation')
}
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Anzeigen
</button>
</div>
)}
{/* Info Box about HinSchG Deadlines (Overview Tab) */}
{activeTab === 'overview' && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-medium text-blue-800">HinSchG-Fristen</h4>
<p className="text-sm text-blue-600 mt-1">
Nach dem Hinweisgeberschutzgesetz (HinSchG) gelten folgende Fristen:
Die Eingangsbestaetigung muss innerhalb von <strong>7 Tagen</strong> an den
Hinweisgeber versendet werden (ss 17 Abs. 1 S. 2).
Eine Rueckmeldung ueber ergriffene Massnahmen muss innerhalb von <strong>3 Monaten</strong> nach
Eingangsbestaetigung erfolgen (ss 17 Abs. 2).
Der Schutz des Hinweisgebers vor Repressalien ist zwingend sicherzustellen (ss 36).
</p>
</div>
</div>
</div>
)}
{/* Filters */}
<FilterBar
selectedCategory={selectedCategory}
selectedStatus={selectedStatus}
selectedPriority={selectedPriority}
onCategoryChange={setSelectedCategory}
onStatusChange={setSelectedStatus}
onPriorityChange={setSelectedPriority}
onClear={clearFilters}
/>
{/* Report List */}
<div className="space-y-4">
{filteredReports.map(report => (
<ReportCard key={report.id} report={report} />
))}
</div>
{/* Empty State */}
{filteredReports.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Meldungen gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
? 'Passen Sie die Filter an oder setzen Sie sie zurueck.'
: 'Es sind noch keine Meldungen im Hinweisgebersystem vorhanden. Meldungen werden ueber das oeffentliche Meldeformular eingereicht.'
}
</p>
{(selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all') && (
<button
onClick={clearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,100 @@
/**
* DSFA Corpus API Proxy
*
* Proxies requests to klausur-service for DSFA RAG operations.
* Endpoints: /api/v1/dsfa-rag/stats, /api/v1/dsfa-rag/sources
*/
import { NextRequest, NextResponse } from 'next/server'
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://klausur-service:8086'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const action = searchParams.get('action')
try {
let url = `${KLAUSUR_SERVICE_URL}/api/v1/dsfa-rag`
switch (action) {
case 'status':
url += '/stats'
break
case 'sources':
url += '/sources'
break
case 'source-detail': {
const code = searchParams.get('code')
if (!code) {
return NextResponse.json({ error: 'Missing code parameter' }, { status: 400 })
}
url += `/sources/${encodeURIComponent(code)}`
break
}
default:
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
}
const res = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
cache: 'no-store',
})
const data = await res.json()
return NextResponse.json(data, { status: res.status })
} catch (error) {
console.error('DSFA corpus proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to klausur-service' },
{ status: 503 }
)
}
}
export async function POST(request: NextRequest) {
const { searchParams } = new URL(request.url)
const action = searchParams.get('action')
try {
let url = `${KLAUSUR_SERVICE_URL}/api/v1/dsfa-rag`
switch (action) {
case 'init': {
url += '/init'
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
const data = await res.json()
return NextResponse.json(data, { status: res.status })
}
case 'ingest': {
const body = await request.json()
const sourceCode = body.source_code
if (!sourceCode) {
return NextResponse.json({ error: 'Missing source_code' }, { status: 400 })
}
url += `/sources/${encodeURIComponent(sourceCode)}/ingest`
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json()
return NextResponse.json(data, { status: res.status })
}
default:
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
}
} catch (error) {
console.error('DSFA corpus proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to klausur-service' },
{ status: 503 }
)
}
}

View File

@@ -8,6 +8,7 @@
import { NextRequest, NextResponse } from 'next/server'
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://klausur-service:8086'
const QDRANT_URL = process.env.QDRANT_URL || 'http://qdrant:6333'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
@@ -17,9 +18,24 @@ export async function GET(request: NextRequest) {
let url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/legal-corpus`
switch (action) {
case 'status':
url += '/status'
break
case 'status': {
// Query Qdrant directly for collection stats
const qdrantRes = await fetch(`${QDRANT_URL}/collections/bp_legal_corpus`, {
cache: 'no-store',
})
if (!qdrantRes.ok) {
return NextResponse.json({ error: 'Qdrant not available' }, { status: 503 })
}
const qdrantData = await qdrantRes.json()
const result = qdrantData.result || {}
return NextResponse.json({
collection: 'bp_legal_corpus',
totalPoints: result.points_count || 0,
vectorSize: result.config?.params?.vectors?.size || 0,
status: result.status || 'unknown',
regulations: {},
})
}
case 'search':
const query = searchParams.get('query')
const topK = searchParams.get('top_k') || '5'

View File

@@ -159,7 +159,7 @@ export async function POST(request: NextRequest) {
stream: true,
options: {
temperature: 0.3,
num_predict: 2048,
num_predict: 8192,
},
}),
signal: AbortSignal.timeout(120000),

View File

@@ -0,0 +1,184 @@
/**
* Drafting Engine Chat API
*
* Verbindet das DraftingEngineWidget mit dem LLM Backend.
* Unterstuetzt alle 4 Modi: explain, ask, draft, validate.
* Nutzt State-Projection fuer token-effiziente Kontextgabe.
*/
import { NextRequest, NextResponse } from 'next/server'
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://klausur-service:8086'
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
// SOUL System Prompt (from agent-core/soul/drafting-agent.soul.md)
const DRAFTING_SYSTEM_PROMPT = `# Drafting Agent - Compliance-Dokumententwurf
## Identitaet
Du bist der BreakPilot Drafting Agent. Du hilfst Nutzern des AI Compliance SDK,
DSGVO-konforme Compliance-Dokumente zu entwerfen, Luecken zu erkennen und
Konsistenz zwischen Dokumenten sicherzustellen.
## Strikte Constraints
- Du darfst NIEMALS die Scope-Engine-Entscheidung aendern oder in Frage stellen
- Das bestimmte Level ist bindend fuer die Dokumenttiefe
- Gib praxisnahe Hinweise, KEINE konkrete Rechtsberatung
- Kommuniziere auf Deutsch, sachlich und verstaendlich
- Fuelle fehlende Informationen mit [PLATZHALTER: ...] Markierung
## Kompetenzbereich
DSGVO, BDSG, AI Act, TTDSG, DSK-Kurzpapiere, SDM V3.0, BSI-Grundschutz, ISO 27001/27701, EDPB Guidelines, WP248`
/**
* Query the RAG corpus for relevant documents
*/
async function queryRAG(query: string): Promise<string> {
try {
const url = `${KLAUSUR_SERVICE_URL}/api/v1/dsfa-rag/search?query=${encodeURIComponent(query)}&top_k=3`
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(10000),
})
if (!res.ok) return ''
const data = await res.json()
if (data.results?.length > 0) {
return data.results
.map(
(r: { source_name?: string; source_code?: string; content?: string }, i: number) =>
`[Quelle ${i + 1}: ${r.source_name || r.source_code || 'Unbekannt'}]\n${r.content || ''}`
)
.join('\n\n---\n\n')
}
return ''
} catch {
return ''
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
message,
history = [],
sdkStateProjection,
mode = 'explain',
documentType,
} = body
if (!message || typeof message !== 'string') {
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
}
// 1. Query RAG for legal context
const ragContext = await queryRAG(message)
// 2. Build system prompt with mode-specific instructions + state projection
let systemContent = DRAFTING_SYSTEM_PROMPT
// Mode-specific instructions
const modeInstructions: Record<string, string> = {
explain: '\n\n## Aktueller Modus: EXPLAIN\nBeantworte Fragen verstaendlich mit Quellenangaben.',
ask: '\n\n## Aktueller Modus: ASK\nAnalysiere Luecken und stelle gezielte Fragen. Eine Frage pro Antwort.',
draft: `\n\n## Aktueller Modus: DRAFT\nEntwirf strukturierte Dokument-Sections. Dokumenttyp: ${documentType || 'nicht spezifiziert'}.\nAntworte mit JSON wenn ein Draft angefragt wird.`,
validate: '\n\n## Aktueller Modus: VALIDATE\nPruefe Cross-Dokument-Konsistenz. Gib Errors, Warnings und Suggestions zurueck.',
}
systemContent += modeInstructions[mode] || modeInstructions.explain
// Add state projection context
if (sdkStateProjection) {
systemContent += `\n\n## SDK-State Projektion (${mode}-Kontext)\n${JSON.stringify(sdkStateProjection, null, 0).slice(0, 3000)}`
}
// Add RAG context
if (ragContext) {
systemContent += `\n\n## Relevanter Rechtskontext\n${ragContext}`
}
// 3. Build messages array
const messages = [
{ role: 'system', content: systemContent },
...history.slice(-10).map((h: { role: string; content: string }) => ({
role: h.role === 'user' ? 'user' : 'assistant',
content: h.content,
})),
{ role: 'user', content: message },
]
// 4. Call LLM with streaming
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: LLM_MODEL,
messages,
stream: true,
options: {
temperature: mode === 'draft' ? 0.2 : 0.3,
num_predict: mode === 'draft' ? 16384 : 8192,
},
}),
signal: AbortSignal.timeout(120000),
})
if (!ollamaResponse.ok) {
const errorText = await ollamaResponse.text()
console.error('LLM error:', ollamaResponse.status, errorText)
return NextResponse.json(
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status})` },
{ status: 502 }
)
}
// 5. Stream response back
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const reader = ollamaResponse.body!.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n').filter((l) => l.trim())
for (const line of lines) {
try {
const json = JSON.parse(line)
if (json.message?.content) {
controller.enqueue(encoder.encode(json.message.content))
}
} catch {
// Partial JSON, skip
}
}
}
} catch (error) {
console.error('Stream error:', error)
} finally {
controller.close()
}
},
})
return new NextResponse(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
} catch (error) {
console.error('Drafting engine chat error:', error)
return NextResponse.json(
{ error: 'Verbindung zum LLM fehlgeschlagen.' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,168 @@
/**
* Drafting Engine - Draft API
*
* Erstellt strukturierte Compliance-Dokument-Entwuerfe.
* Baut dokument-spezifische Prompts aus SOUL-Template + State-Projection.
* Gibt strukturiertes JSON zurueck.
*/
import { NextRequest, NextResponse } from 'next/server'
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
// Import prompt builders
import { buildVVTDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-vvt'
import { buildTOMDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-tom'
import { buildDSFADraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-dsfa'
import { buildPrivacyPolicyDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-privacy-policy'
import { buildLoeschfristenDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-loeschfristen'
import type { DraftContext, DraftResponse, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
import { ConstraintEnforcer } from '@/lib/sdk/drafting-engine/constraint-enforcer'
const constraintEnforcer = new ConstraintEnforcer()
const DRAFTING_SYSTEM_PROMPT = `Du bist ein DSGVO-Compliance-Experte und erstellst strukturierte Dokument-Entwuerfe.
Du MUSST immer im JSON-Format antworten mit einem "sections" Array.
Jede Section hat: id, title, content, schemaField.
Halte die Tiefe strikt am vorgegebenen Level.
Markiere fehlende Informationen mit [PLATZHALTER: Beschreibung].
Sprache: Deutsch.`
function buildPromptForDocumentType(
documentType: ScopeDocumentType,
context: DraftContext,
instructions?: string
): string {
switch (documentType) {
case 'vvt':
return buildVVTDraftPrompt({ context, instructions })
case 'tom':
return buildTOMDraftPrompt({ context, instructions })
case 'dsfa':
return buildDSFADraftPrompt({ context, instructions })
case 'dsi':
return buildPrivacyPolicyDraftPrompt({ context, instructions })
case 'lf':
return buildLoeschfristenDraftPrompt({ context, instructions })
default:
return `## Aufgabe: Entwurf fuer ${documentType}
### Level: ${context.decisions.level}
### Tiefe: ${context.constraints.depthRequirements.depth}
### Erforderliche Inhalte:
${context.constraints.depthRequirements.detailItems.map((item, i) => `${i + 1}. ${item}`).join('\n')}
${instructions ? `### Anweisungen: ${instructions}` : ''}
Antworte als JSON mit "sections" Array.`
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { documentType, draftContext, instructions, existingDraft } = body
if (!documentType || !draftContext) {
return NextResponse.json(
{ error: 'documentType und draftContext sind erforderlich' },
{ status: 400 }
)
}
// 1. Constraint Check (Hard Gate)
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
if (!constraintCheck.allowed) {
return NextResponse.json({
draft: null,
constraintCheck,
tokensUsed: 0,
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
}, { status: 403 })
}
// 2. Build document-specific prompt
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
// 3. Build messages
const messages = [
{ role: 'system', content: DRAFTING_SYSTEM_PROMPT },
...(existingDraft ? [{
role: 'assistant',
content: `Bisheriger Entwurf:\n${JSON.stringify(existingDraft.sections, null, 2)}`,
}] : []),
{ role: 'user', content: draftPrompt },
]
// 4. Call LLM (non-streaming for structured output)
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: LLM_MODEL,
messages,
stream: false,
options: {
temperature: 0.15,
num_predict: 16384,
},
format: 'json',
}),
signal: AbortSignal.timeout(180000),
})
if (!ollamaResponse.ok) {
return NextResponse.json(
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status})` },
{ status: 502 }
)
}
const result = await ollamaResponse.json()
const content = result.message?.content || ''
// 5. Parse JSON response
let sections: DraftSection[] = []
try {
const parsed = JSON.parse(content)
sections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
id: String(s.id || `section-${i}`),
title: String(s.title || ''),
content: String(s.content || ''),
schemaField: s.schemaField ? String(s.schemaField) : undefined,
}))
} catch {
// If not JSON, wrap raw content as single section
sections = [{
id: 'raw',
title: 'Entwurf',
content: content,
}]
}
const draft: DraftRevision = {
id: `draft-${Date.now()}`,
content: sections.map(s => `## ${s.title}\n\n${s.content}`).join('\n\n'),
sections,
createdAt: new Date().toISOString(),
instruction: instructions,
}
const response: DraftResponse = {
draft,
constraintCheck,
tokensUsed: result.eval_count || 0,
}
return NextResponse.json(response)
} catch (error) {
console.error('Draft generation error:', error)
return NextResponse.json(
{ error: 'Draft-Generierung fehlgeschlagen.' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,188 @@
/**
* Drafting Engine - Validate API
*
* Stufe 1: Deterministische Pruefung gegen DOCUMENT_SCOPE_MATRIX
* Stufe 2: LLM Cross-Consistency Check
*/
import { NextRequest, NextResponse } from 'next/server'
import { DOCUMENT_SCOPE_MATRIX, DOCUMENT_TYPE_LABELS, getDepthLevelNumeric } from '@/lib/sdk/compliance-scope-types'
import type { ScopeDocumentType, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
import type { ValidationContext, ValidationResult, ValidationFinding } from '@/lib/sdk/drafting-engine/types'
import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validate-cross-check'
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
/**
* Stufe 1: Deterministische Pruefung
*/
function deterministicCheck(
documentType: ScopeDocumentType,
validationContext: ValidationContext
): ValidationFinding[] {
const findings: ValidationFinding[] = []
const level = validationContext.scopeLevel
const levelNumeric = getDepthLevelNumeric(level)
const req = DOCUMENT_SCOPE_MATRIX[documentType]?.[level]
// Check 1: Ist das Dokument auf diesem Level erforderlich?
if (req && !req.required && levelNumeric < 3) {
findings.push({
id: `DET-OPT-${documentType}`,
severity: 'suggestion',
category: 'scope_violation',
title: `${DOCUMENT_TYPE_LABELS[documentType] ?? documentType} ist optional`,
description: `Auf Level ${level} ist dieses Dokument nicht verpflichtend.`,
documentType,
})
}
// Check 2: VVT vorhanden wenn erforderlich?
const vvtReq = DOCUMENT_SCOPE_MATRIX.vvt[level]
if (vvtReq.required && validationContext.crossReferences.vvtCategories.length === 0) {
findings.push({
id: 'DET-VVT-MISSING',
severity: 'error',
category: 'missing_content',
title: 'VVT fehlt',
description: `Auf Level ${level} ist ein VVT Pflicht, aber keine Eintraege vorhanden.`,
documentType: 'vvt',
legalReference: 'Art. 30 DSGVO',
})
}
// Check 3: TOM vorhanden wenn VVT existiert?
if (validationContext.crossReferences.vvtCategories.length > 0
&& validationContext.crossReferences.tomControls.length === 0) {
findings.push({
id: 'DET-TOM-MISSING-FOR-VVT',
severity: 'warning',
category: 'cross_reference',
title: 'TOM fehlt bei vorhandenem VVT',
description: 'VVT-Eintraege existieren, aber keine TOM-Massnahmen sind definiert.',
documentType: 'tom',
crossReferenceType: 'vvt',
legalReference: 'Art. 32 DSGVO',
suggestion: 'TOM-Massnahmen erstellen, die die VVT-Taetigkeiten absichern.',
})
}
// Check 4: Loeschfristen fuer VVT-Kategorien
if (validationContext.crossReferences.vvtCategories.length > 0
&& validationContext.crossReferences.retentionCategories.length === 0) {
findings.push({
id: 'DET-LF-MISSING-FOR-VVT',
severity: 'warning',
category: 'cross_reference',
title: 'Loeschfristen fehlen',
description: 'VVT-Eintraege existieren, aber keine Loeschfristen sind definiert.',
documentType: 'lf',
crossReferenceType: 'vvt',
legalReference: 'Art. 17 DSGVO',
suggestion: 'Loeschfristen fuer alle VVT-Datenkategorien definieren.',
})
}
return findings
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { documentType, draftContent, validationContext } = body
if (!documentType || !validationContext) {
return NextResponse.json(
{ error: 'documentType und validationContext sind erforderlich' },
{ status: 400 }
)
}
// ---------------------------------------------------------------
// Stufe 1: Deterministische Pruefung
// ---------------------------------------------------------------
const deterministicFindings = deterministicCheck(documentType, validationContext)
// ---------------------------------------------------------------
// Stufe 2: LLM Cross-Consistency Check
// ---------------------------------------------------------------
let llmFindings: ValidationFinding[] = []
try {
const crossCheckPrompt = buildCrossCheckPrompt({
context: validationContext,
})
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: LLM_MODEL,
messages: [
{
role: 'system',
content: 'Du bist ein DSGVO-Compliance-Validator. Antworte NUR im JSON-Format.',
},
{ role: 'user', content: crossCheckPrompt },
],
stream: false,
options: { temperature: 0.1, num_predict: 8192 },
format: 'json',
}),
signal: AbortSignal.timeout(120000),
})
if (ollamaResponse.ok) {
const result = await ollamaResponse.json()
try {
const parsed = JSON.parse(result.message?.content || '{}')
llmFindings = [
...(parsed.errors || []),
...(parsed.warnings || []),
...(parsed.suggestions || []),
].map((f: Record<string, unknown>, i: number) => ({
id: String(f.id || `LLM-${i}`),
severity: (String(f.severity || 'suggestion')) as 'error' | 'warning' | 'suggestion',
category: (String(f.category || 'inconsistency')) as ValidationFinding['category'],
title: String(f.title || ''),
description: String(f.description || ''),
documentType: (String(f.documentType || documentType)) as ScopeDocumentType,
crossReferenceType: f.crossReferenceType ? String(f.crossReferenceType) as ScopeDocumentType : undefined,
legalReference: f.legalReference ? String(f.legalReference) : undefined,
suggestion: f.suggestion ? String(f.suggestion) : undefined,
}))
} catch {
// LLM response not parseable, skip
}
}
} catch {
// LLM unavailable, continue with deterministic results only
}
// ---------------------------------------------------------------
// Combine results
// ---------------------------------------------------------------
const allFindings = [...deterministicFindings, ...llmFindings]
const errors = allFindings.filter(f => f.severity === 'error')
const warnings = allFindings.filter(f => f.severity === 'warning')
const suggestions = allFindings.filter(f => f.severity === 'suggestion')
const result: ValidationResult = {
passed: errors.length === 0,
timestamp: new Date().toISOString(),
scopeLevel: validationContext.scopeLevel,
errors,
warnings,
suggestions,
}
return NextResponse.json(result)
} catch (error) {
console.error('Validation error:', error)
return NextResponse.json(
{ error: 'Validierung fehlgeschlagen.' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,136 @@
/**
* Academy API Proxy - Catch-all route
* Proxies all /api/sdk/v1/academy/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/academy`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body - continue without
}
}
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (e.g., PDF certificates)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/octet-stream')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Academy API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,114 @@
/**
* Document Crawler API Proxy - Catch-all route
* Proxies all /api/sdk/v1/crawler/* requests to document-crawler service (port 8098)
*/
import { NextRequest, NextResponse } from 'next/server'
const CRAWLER_BACKEND_URL = process.env.CRAWLER_API_URL || 'http://document-crawler:8098'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${CRAWLER_BACKEND_URL}/api/v1/crawler`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
// Forward all relevant headers
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
// Forward body for non-GET requests
if (method !== 'GET' && method !== 'DELETE') {
try {
const body = await request.json()
fetchOptions.body = JSON.stringify(body)
} catch {
// No body or non-JSON body
}
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
// Handle 204 No Content
if (response.status === 204) {
return new NextResponse(null, { status: 204 })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Document Crawler API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum Document Crawler Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,109 @@
/**
* DSB Portal API Proxy - Catch-all route
* Proxies all /api/sdk/v1/dsb/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/dsb`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
try {
const body = await request.text()
if (body) {
fetchOptions.body = body
}
} catch {
// No body to forward
}
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('DSB API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,137 @@
/**
* Incidents/Breach Management API Proxy - Catch-all route
* Proxies all /api/sdk/v1/incidents/* requests to ai-compliance-sdk backend
* Supports PDF generation for authority notification forms
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/incidents`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body
}
}
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (PDF authority forms, exports)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/octet-stream')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Incidents API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,74 @@
/**
* Industry Templates API Proxy - Catch-all route
* Proxies all /api/sdk/v1/industry/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/industry`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Industry API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}

View File

@@ -0,0 +1,111 @@
/**
* Multi-Tenant API Proxy - Catch-all route
* Proxies all /api/sdk/v1/multi-tenant/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/multi-tenant`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
// Forward all relevant headers
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
// Forward body for POST/PUT/PATCH/DELETE
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
try {
const body = await request.text()
if (body) {
fetchOptions.body = body
}
} catch {
// No body to forward
}
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Multi-Tenant API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,75 @@
/**
* Reporting API Proxy - Catch-all route
* Proxies all /api/sdk/v1/reporting/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/reporting`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
// Forward all relevant headers
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Reporting API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}

View File

@@ -0,0 +1,111 @@
/**
* SSO API Proxy - Catch-all route
* Proxies all /api/sdk/v1/sso/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/sso`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
// Forward all relevant headers
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
// Forward body for POST/PUT/PATCH/DELETE
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
try {
const body = await request.text()
if (body) {
fetchOptions.body = body
}
} catch {
// No body to forward
}
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('SSO API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,135 @@
/**
* Vendor Compliance API Proxy - Catch-all route
* Proxies all /api/sdk/v1/vendors/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/vendors`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
// Forward all relevant headers
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body - continue without
}
}
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (e.g., PDF exports)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/octet-stream')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Vendor Compliance API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,147 @@
/**
* Whistleblower API Proxy - Catch-all route
* Proxies all /api/sdk/v1/whistleblower/* requests to ai-compliance-sdk backend
* Supports multipart/form-data for file uploads
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/whistleblower`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {}
const contentType = request.headers.get('content-type')
// Forward auth headers
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(60000), // 60s for file uploads
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
if (contentType?.includes('multipart/form-data')) {
// Forward multipart form data (file uploads)
const formData = await request.formData()
fetchOptions.body = formData
// Don't set Content-Type - let fetch set it with boundary
} else if (contentType?.includes('application/json')) {
headers['Content-Type'] = 'application/json'
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body
}
} else {
headers['Content-Type'] = 'application/json'
}
} else {
headers['Content-Type'] = 'application/json'
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (e.g., PDF exports, file downloads)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/octet-stream') ||
responseContentType?.includes('image/')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Whistleblower API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,65 @@
/**
* Website Content API Route
*
* GET: Load current website content
* POST: Save changed content (Admin only)
*/
import { NextRequest, NextResponse } from 'next/server'
import { getContent, saveContent } from '@/lib/content'
import type { WebsiteContent } from '@/lib/content-types'
// GET - Load content
export async function GET() {
try {
const content = getContent()
return NextResponse.json(content)
} catch (error) {
console.error('Error loading content:', error)
return NextResponse.json(
{ error: 'Failed to load content' },
{ status: 500 }
)
}
}
// POST - Save content
export async function POST(request: NextRequest) {
try {
const adminKey = request.headers.get('x-admin-key')
const expectedKey = process.env.ADMIN_API_KEY || 'breakpilot-admin-2024'
if (adminKey !== expectedKey) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const content: WebsiteContent = await request.json()
if (!content.hero || !content.features || !content.faq || !content.pricing) {
return NextResponse.json(
{ error: 'Invalid content structure' },
{ status: 400 }
)
}
const result = saveContent(content)
if (result.success) {
return NextResponse.json({ success: true, message: 'Content saved' })
} else {
return NextResponse.json(
{ error: result.error || 'Failed to save content' },
{ status: 500 }
)
}
} catch (error) {
console.error('Error saving content:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to save content' },
{ status: 500 }
)
}
}

Some files were not shown because too many files have changed in this diff Show More