This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/admin-v2/components/sdk/SDKPipelineSidebar/SDKPipelineSidebar.tsx
BreakPilot Dev f09e24d52c refactor(admin-v2): Consolidate compliance/DSGVO pages into SDK pipeline
Remove duplicate compliance and DSGVO admin pages that have been superseded
by the unified SDK pipeline. Update navigation, sidebar, roles, and module
registry to reflect the new structure. Add DSFA corpus API proxy and
source-policy components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:26:05 +01:00

541 lines
18 KiB
TypeScript

'use client'
/**
* SDK Pipeline Sidebar
*
* Floating Action Button mit Drawer zur Visualisierung der SDK-Pipeline.
* Zeigt die 5 Pakete mit Fortschritt und ermoeglicht schnelle Navigation.
*
* Features:
* - Desktop (xl+): Fixierte Sidebar rechts
* - Mobile/Tablet: Floating Action Button mit Slide-In Drawer
*/
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { useSDK, SDK_STEPS, SDK_PACKAGES, getStepsForPackage, type SDKStep, type SDKPackageId } from '@/lib/sdk'
// =============================================================================
// ICONS
// =============================================================================
const CheckIcon = () => (
<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>
)
const LockIcon = () => (
<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 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>
)
const ArrowIcon = () => (
<svg className="w-3 h-3 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>
)
const CloseIcon = () => (
<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>
)
const PipelineIcon = () => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
)
// =============================================================================
// STEP ITEM
// =============================================================================
interface StepItemProps {
step: SDKStep
isActive: boolean
isCompleted: boolean
onNavigate: () => void
}
function StepItem({ step, isActive, isCompleted, onNavigate }: StepItemProps) {
return (
<Link
href={step.url}
onClick={onNavigate}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-all ${
isActive
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 font-medium'
: isCompleted
? 'text-green-600 dark:text-green-400 hover:bg-slate-100 dark:hover:bg-gray-800'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
}`}
>
<span className="flex-1 text-sm truncate">{step.nameShort}</span>
{isCompleted && !isActive && (
<span className="flex-shrink-0 w-4 h-4 bg-green-500 text-white rounded-full flex items-center justify-center">
<CheckIcon />
</span>
)}
{isActive && (
<span className="flex-shrink-0 w-2 h-2 bg-purple-500 rounded-full animate-pulse" />
)}
</Link>
)
}
// =============================================================================
// PACKAGE SECTION
// =============================================================================
interface PackageSectionProps {
pkg: (typeof SDK_PACKAGES)[number]
steps: SDKStep[]
completion: number
currentStepId: string
completedSteps: string[]
isLocked: boolean
onNavigate: () => void
isExpanded: boolean
onToggle: () => void
}
function PackageSection({
pkg,
steps,
completion,
currentStepId,
completedSteps,
isLocked,
onNavigate,
isExpanded,
onToggle,
}: PackageSectionProps) {
return (
<div className="space-y-2">
{/* Package Header */}
<button
onClick={onToggle}
disabled={isLocked}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${
isLocked
? 'bg-slate-100 dark:bg-gray-800 opacity-50 cursor-not-allowed'
: 'bg-slate-50 dark:bg-gray-800 hover:bg-slate-100 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center gap-2">
<div
className={`w-7 h-7 rounded-full flex items-center justify-center text-sm ${
isLocked
? 'bg-gray-200 text-gray-400'
: completion === 100
? 'bg-green-500 text-white'
: 'bg-purple-600 text-white'
}`}
>
{isLocked ? <LockIcon /> : completion === 100 ? <CheckIcon /> : pkg.icon}
</div>
<div className="text-left">
<div className={`text-sm font-medium ${isLocked ? 'text-slate-400' : 'text-slate-700 dark:text-slate-200'}`}>
{pkg.order}. {pkg.nameShort}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">{completion}%</div>
</div>
</div>
{!isLocked && (
<svg
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? '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>
{/* Progress Bar */}
{!isLocked && (
<div className="px-3">
<div className="h-1.5 bg-slate-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
completion === 100 ? 'bg-green-500' : 'bg-purple-600'
}`}
style={{ width: `${completion}%` }}
/>
</div>
</div>
)}
{/* Steps List */}
{isExpanded && !isLocked && (
<div className="space-y-1 pl-2">
{steps.map(step => (
<StepItem
key={step.id}
step={step}
isActive={currentStepId === step.id}
isCompleted={completedSteps.includes(step.id)}
onNavigate={onNavigate}
/>
))}
</div>
)}
</div>
)
}
// =============================================================================
// PIPELINE FLOW VISUALIZATION
// =============================================================================
function PipelineFlow() {
return (
<div className="pt-3 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
Datenfluss
</div>
<div className="p-3 bg-slate-50 dark:bg-gray-900 rounded-lg">
<div className="flex flex-col gap-1.5">
{SDK_PACKAGES.map((pkg, idx) => (
<div key={pkg.id} className="flex items-center gap-2 text-xs">
<span className="w-5 h-5 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
{pkg.icon}
</span>
<span className="text-slate-600 dark:text-slate-400 flex-1">{pkg.nameShort}</span>
{idx < SDK_PACKAGES.length - 1 && <ArrowIcon />}
</div>
))}
</div>
</div>
</div>
)
}
// =============================================================================
// SIDEBAR CONTENT
// =============================================================================
interface SidebarContentProps {
onNavigate: () => void
}
function SidebarContent({ onNavigate }: SidebarContentProps) {
const pathname = usePathname()
const { state, packageCompletion } = useSDK()
const [expandedPackages, setExpandedPackages] = useState<Record<SDKPackageId, boolean>>({
'vorbereitung': true,
'analyse': false,
'dokumentation': false,
'rechtliche-texte': false,
'betrieb': false,
})
// Find current step
const currentStep = SDK_STEPS.find(s => s.url === pathname)
const currentStepId = currentStep?.id || ''
// Auto-expand current package
useEffect(() => {
if (currentStep) {
setExpandedPackages(prev => ({
...prev,
[currentStep.package]: true,
}))
}
}, [currentStep])
const togglePackage = (packageId: SDKPackageId) => {
setExpandedPackages(prev => ({ ...prev, [packageId]: !prev[packageId] }))
}
const isPackageLocked = (packageId: SDKPackageId): boolean => {
if (state.preferences?.allowParallelWork) return false
const pkg = SDK_PACKAGES.find(p => p.id === packageId)
if (!pkg || pkg.order === 1) return false
const prevPkg = SDK_PACKAGES.find(p => p.order === pkg.order - 1)
if (!prevPkg) return false
return packageCompletion[prevPkg.id] < 100
}
// Get visible steps based on customer type
const getVisibleSteps = (packageId: SDKPackageId): SDKStep[] => {
const steps = getStepsForPackage(packageId)
return steps.filter(step => {
if (step.id === 'import' && state.customerType === 'new') {
return false
}
return true
})
}
return (
<div className="space-y-4">
{/* Packages */}
{SDK_PACKAGES.map(pkg => (
<PackageSection
key={pkg.id}
pkg={pkg}
steps={getVisibleSteps(pkg.id)}
completion={packageCompletion[pkg.id]}
currentStepId={currentStepId}
completedSteps={state.completedSteps}
isLocked={isPackageLocked(pkg.id)}
onNavigate={onNavigate}
isExpanded={expandedPackages[pkg.id]}
onToggle={() => togglePackage(pkg.id)}
/>
))}
{/* Pipeline Flow */}
<PipelineFlow />
{/* Quick Info */}
{currentStep && (
<div className="pt-3 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-lg">
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong>{' '}
{currentStep.description}
</div>
</div>
)}
</div>
)
}
// =============================================================================
// MAIN COMPONENT - RESPONSIVE
// =============================================================================
export interface SDKPipelineSidebarProps {
/** Position des FAB auf Mobile */
fabPosition?: 'bottom-right' | 'bottom-left'
}
export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipelineSidebarProps) {
const [isMobileOpen, setIsMobileOpen] = useState(false)
const [isDesktopCollapsed, setIsDesktopCollapsed] = useState(true) // Start collapsed
const { completionPercentage } = useSDK()
// Load collapsed state from localStorage
useEffect(() => {
const stored = localStorage.getItem('sdk-pipeline-sidebar-collapsed')
if (stored !== null) {
setIsDesktopCollapsed(stored === 'true')
}
}, [])
// Save collapsed state to localStorage
const toggleDesktopSidebar = () => {
const newState = !isDesktopCollapsed
setIsDesktopCollapsed(newState)
localStorage.setItem('sdk-pipeline-sidebar-collapsed', String(newState))
}
// Close drawer on route change or escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsMobileOpen(false)
setIsDesktopCollapsed(true)
}
}
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [])
// Prevent body scroll when drawer is open
useEffect(() => {
if (isMobileOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isMobileOpen])
const fabPositionClasses = fabPosition === 'bottom-right'
? 'right-4 bottom-6'
: 'left-4 bottom-6'
return (
<>
{/* Desktop: Fixed Sidebar (when expanded) */}
{!isDesktopCollapsed && (
<div className="hidden xl:block fixed right-6 top-24 w-72 z-10">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-slate-200 dark:border-gray-700 overflow-hidden">
{/* Header with close button */}
<div className="px-4 py-3 bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20 border-b border-slate-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-purple-600 dark:text-purple-400">
<PipelineIcon />
</span>
<div>
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
SDK Pipeline
</span>
<span className="ml-2 text-xs text-purple-600 dark:text-purple-400">
{completionPercentage}%
</span>
</div>
</div>
<button
onClick={toggleDesktopSidebar}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Sidebar einklappen"
title="Einklappen"
>
<CloseIcon />
</button>
</div>
</div>
{/* Content */}
<div className="p-3 max-h-[calc(100vh-200px)] overflow-y-auto">
<SidebarContent onNavigate={() => {}} />
</div>
</div>
</div>
)}
{/* Desktop: FAB (when collapsed) */}
{isDesktopCollapsed && (
<button
onClick={toggleDesktopSidebar}
className={`hidden xl:flex fixed right-6 bottom-6 z-40 w-14 h-14 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all items-center justify-center group`}
aria-label="SDK Pipeline Navigation oeffnen"
title="Pipeline anzeigen"
>
<PipelineIcon />
{/* Progress indicator */}
<svg
className="absolute inset-0 w-full h-full -rotate-90"
viewBox="0 0 56 56"
>
<circle
cx="28"
cy="28"
r="26"
fill="none"
stroke="rgba(255,255,255,0.3)"
strokeWidth="2"
/>
<circle
cx="28"
cy="28"
r="26"
fill="none"
stroke="white"
strokeWidth="2"
strokeDasharray={`${(completionPercentage / 100) * 163.36} 163.36`}
strokeLinecap="round"
/>
</svg>
</button>
)}
{/* Mobile/Tablet: FAB */}
<button
onClick={() => setIsMobileOpen(true)}
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
aria-label="SDK Pipeline Navigation oeffnen"
>
<PipelineIcon />
{/* Progress indicator */}
<svg
className="absolute inset-0 w-full h-full -rotate-90"
viewBox="0 0 56 56"
>
<circle
cx="28"
cy="28"
r="26"
fill="none"
stroke="rgba(255,255,255,0.3)"
strokeWidth="2"
/>
<circle
cx="28"
cy="28"
r="26"
fill="none"
stroke="white"
strokeWidth="2"
strokeDasharray={`${(completionPercentage / 100) * 163.36} 163.36`}
strokeLinecap="round"
/>
</svg>
</button>
{/* Mobile/Tablet: Drawer Overlay */}
{isMobileOpen && (
<div className="xl:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
onClick={() => setIsMobileOpen(false)}
/>
{/* Drawer */}
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
{/* Drawer Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20">
<div className="flex items-center gap-2">
<span className="text-purple-600 dark:text-purple-400">
<PipelineIcon />
</span>
<div>
<span className="font-semibold text-slate-700 dark:text-slate-200">
SDK Pipeline
</span>
<span className="ml-2 text-sm text-purple-600 dark:text-purple-400">
{completionPercentage}%
</span>
</div>
</div>
<button
onClick={() => setIsMobileOpen(false)}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Schliessen"
>
<CloseIcon />
</button>
</div>
{/* Drawer Content */}
<div className="p-4 overflow-y-auto max-h-[calc(100vh-80px)]">
<SidebarContent onNavigate={() => setIsMobileOpen(false)} />
</div>
</div>
</div>
)}
{/* CSS for slide-in animation */}
<style jsx>{`
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: slide-in-right 0.2s ease-out;
}
`}</style>
</>
)
}
export default SDKPipelineSidebar