refactor(admin): split loeschfristen + dsb-portal page.tsx into colocated components
Split two oversized page files into _components/ directories following Next.js 15 conventions and the 500-LOC hard cap: - loeschfristen/page.tsx (2322 LOC -> 412 LOC orchestrator + 6 components) - dsb-portal/page.tsx (2068 LOC -> 135 LOC orchestrator + 9 components) All component files stay under 500 lines. Build verified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
LoeschfristPolicy,
|
||||
RETENTION_DRIVER_META,
|
||||
formatRetentionDuration,
|
||||
getEffectiveDeletionTrigger,
|
||||
} from '@/lib/sdk/loeschfristen-types'
|
||||
import {
|
||||
PROFILING_STEPS, ProfilingAnswer, ProfilingStep,
|
||||
isStepComplete, getProfilingProgress,
|
||||
} from '@/lib/sdk/loeschfristen-profiling'
|
||||
import { renderTriggerBadge } from './UebersichtTab'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GeneratorTabProps {
|
||||
profilingStep: number
|
||||
setProfilingStep: (s: number | ((prev: number) => number)) => void
|
||||
profilingAnswers: ProfilingAnswer[]
|
||||
handleProfilingAnswer: (stepIndex: number, questionId: string, value: any) => void
|
||||
generatedPolicies: LoeschfristPolicy[]
|
||||
setGeneratedPolicies: (p: LoeschfristPolicy[]) => void
|
||||
selectedGenerated: Set<string>
|
||||
setSelectedGenerated: (s: Set<string>) => void
|
||||
handleGenerate: () => void
|
||||
adoptGeneratedPolicies: (onlySelected: boolean) => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generated policies preview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GeneratedPreview({
|
||||
generatedPolicies,
|
||||
selectedGenerated,
|
||||
setSelectedGenerated,
|
||||
setGeneratedPolicies,
|
||||
adoptGeneratedPolicies,
|
||||
}: Pick<
|
||||
GeneratorTabProps,
|
||||
'generatedPolicies' | 'selectedGenerated' | 'setSelectedGenerated' | 'setGeneratedPolicies' | 'adoptGeneratedPolicies'
|
||||
>) {
|
||||
return (
|
||||
<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-2">Generierte Loeschfristen</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Auf Basis Ihres Profils wurden {generatedPolicies.length} Loeschfristen generiert.
|
||||
Waehlen Sie die relevanten aus und uebernehmen Sie sie.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 mb-4">
|
||||
<button
|
||||
onClick={() => setSelectedGenerated(new Set(generatedPolicies.map((p) => p.policyId)))}
|
||||
className="text-sm text-purple-600 hover:text-purple-800 font-medium"
|
||||
>
|
||||
Alle auswaehlen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedGenerated(new Set())}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 font-medium"
|
||||
>
|
||||
Alle abwaehlen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto">
|
||||
{generatedPolicies.map((gp) => {
|
||||
const selected = selectedGenerated.has(gp.policyId)
|
||||
return (
|
||||
<label
|
||||
key={gp.policyId}
|
||||
className={`flex items-start gap-3 p-4 border rounded-lg cursor-pointer transition ${
|
||||
selected ? 'border-purple-300 bg-purple-50' : 'border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={(e) => {
|
||||
const next = new Set(selectedGenerated)
|
||||
if (e.target.checked) next.add(gp.policyId)
|
||||
else next.delete(gp.policyId)
|
||||
setSelectedGenerated(next)
|
||||
}}
|
||||
className="mt-1 text-purple-600 focus:ring-purple-500 rounded"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{gp.dataObjectName}</span>
|
||||
<span className="text-xs font-mono text-gray-400">{gp.policyId}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-1">{gp.description}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{renderTriggerBadge(getEffectiveDeletionTrigger(gp))}
|
||||
<span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">
|
||||
{formatRetentionDuration(gp)}
|
||||
</span>
|
||||
{gp.retentionDriver && (
|
||||
<span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
|
||||
{RETENTION_DRIVER_META[gp.retentionDriver]?.label || gp.retentionDriver}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => { setGeneratedPolicies([]); setSelectedGenerated(new Set()) }}
|
||||
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
|
||||
>
|
||||
Zurueck zum Profiling
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => adoptGeneratedPolicies(false)}
|
||||
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
|
||||
>
|
||||
Alle uebernehmen ({generatedPolicies.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => adoptGeneratedPolicies(true)}
|
||||
disabled={selectedGenerated.size === 0}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Ausgewaehlte uebernehmen ({selectedGenerated.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profiling wizard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProfilingWizard({
|
||||
profilingStep,
|
||||
setProfilingStep,
|
||||
profilingAnswers,
|
||||
handleProfilingAnswer,
|
||||
handleGenerate,
|
||||
}: Pick<
|
||||
GeneratorTabProps,
|
||||
'profilingStep' | 'setProfilingStep' | 'profilingAnswers' | 'handleProfilingAnswer' | 'handleGenerate'
|
||||
>) {
|
||||
const totalSteps = PROFILING_STEPS.length
|
||||
const progress = getProfilingProgress(profilingAnswers)
|
||||
const allComplete = PROFILING_STEPS.every((step, idx) =>
|
||||
isStepComplete(step, profilingAnswers.filter((a) => a.stepIndex === idx)),
|
||||
)
|
||||
const currentStep: ProfilingStep | undefined = PROFILING_STEPS[profilingStep]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress bar */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Profiling-Assistent</h3>
|
||||
<span className="text-sm text-gray-500">Schritt {profilingStep + 1} von {totalSteps}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-purple-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between mt-2">
|
||||
{PROFILING_STEPS.map((step, idx) => (
|
||||
<button key={idx} onClick={() => setProfilingStep(idx)}
|
||||
className={`text-xs font-medium transition ${
|
||||
idx === profilingStep ? 'text-purple-600' : idx < profilingStep ? 'text-green-600' : 'text-gray-400'
|
||||
}`}>
|
||||
{step.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current step questions */}
|
||||
{currentStep && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">{currentStep.title}</h3>
|
||||
{currentStep.description && <p className="text-sm text-gray-500">{currentStep.description}</p>}
|
||||
</div>
|
||||
|
||||
{currentStep.questions.map((question) => {
|
||||
const currentAnswer = profilingAnswers.find(
|
||||
(a) => a.stepIndex === profilingStep && a.questionId === question.id,
|
||||
)
|
||||
return (
|
||||
<div key={question.id} className="border-t border-gray-100 pt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{question.label}
|
||||
{question.helpText && (
|
||||
<span className="block text-xs text-gray-400 font-normal mt-0.5">{question.helpText}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{question.type === 'boolean' && (
|
||||
<div className="flex gap-3">
|
||||
{[{ val: true, label: 'Ja' }, { val: false, label: 'Nein' }].map((opt) => (
|
||||
<button key={String(opt.val)}
|
||||
onClick={() => handleProfilingAnswer(profilingStep, question.id, opt.val)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
|
||||
currentAnswer?.value === opt.val
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.type === 'single' && question.options && (
|
||||
<div className="space-y-2">
|
||||
{question.options.map((opt) => (
|
||||
<label key={opt.value}
|
||||
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${
|
||||
currentAnswer?.value === opt.value ? 'border-purple-300 bg-purple-50' : 'border-gray-200 hover:bg-gray-50'
|
||||
}`}>
|
||||
<input type="radio" name={`${question.id}-${profilingStep}`}
|
||||
checked={currentAnswer?.value === opt.value}
|
||||
onChange={() => handleProfilingAnswer(profilingStep, question.id, opt.value)}
|
||||
className="text-purple-600 focus:ring-purple-500" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">{opt.label}</span>
|
||||
{opt.description && <span className="block text-xs text-gray-500">{opt.description}</span>}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.type === 'multi' && question.options && (
|
||||
<div className="space-y-2">
|
||||
{question.options.map((opt) => {
|
||||
const selectedValues: string[] = currentAnswer?.value || []
|
||||
const isSelected = selectedValues.includes(opt.value)
|
||||
return (
|
||||
<label key={opt.value}
|
||||
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${
|
||||
isSelected ? 'border-purple-300 bg-purple-50' : 'border-gray-200 hover:bg-gray-50'
|
||||
}`}>
|
||||
<input type="checkbox" checked={isSelected}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked
|
||||
? [...selectedValues, opt.value]
|
||||
: selectedValues.filter((v) => v !== opt.value)
|
||||
handleProfilingAnswer(profilingStep, question.id, next)
|
||||
}}
|
||||
className="text-purple-600 focus:ring-purple-500 rounded" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">{opt.label}</span>
|
||||
{opt.description && <span className="block text-xs text-gray-500">{opt.description}</span>}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.type === 'number' && (
|
||||
<input type="number" value={currentAnswer?.value ?? ''}
|
||||
onChange={(e) => handleProfilingAnswer(profilingStep, question.id, e.target.value ? parseInt(e.target.value) : '')}
|
||||
min={0} placeholder="Bitte Zahl eingeben"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setProfilingStep((s: number) => Math.max(0, s - 1))}
|
||||
disabled={profilingStep === 0}
|
||||
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
|
||||
{profilingStep < totalSteps - 1 ? (
|
||||
<button
|
||||
onClick={() => setProfilingStep((s: number) => Math.min(totalSteps - 1, s + 1))}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 font-medium transition"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleGenerate} disabled={!allComplete}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-5 py-2.5 font-semibold transition disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Loeschfristen generieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function GeneratorTab(props: GeneratorTabProps) {
|
||||
if (props.generatedPolicies.length > 0) {
|
||||
return <GeneratedPreview {...props} />
|
||||
}
|
||||
return <ProfilingWizard {...props} />
|
||||
}
|
||||
Reference in New Issue
Block a user