fix(admin-v2): Add missing QRCodeUpload component and fix SDK imports

- Create admin-v2 compatible QRCodeUpload component (adapted from studio-v2)
- Restore working DataPointsPreview and DocumentValidation from HEAD

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:58:57 +01:00
parent 10d0f4c949
commit 9cc357962f
3 changed files with 489 additions and 361 deletions

View File

@@ -1,40 +1,6 @@
'use client'
import { useMemo } from 'react'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
Database,
FileText,
List,
Table,
AlertTriangle,
Shield,
Clock,
Users,
Globe,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import {
DataPoint,
DataPointCategory,
@@ -56,79 +22,70 @@ const PLACEHOLDERS = [
{
placeholder: '[DATENPUNKTE_TABLE]',
label: { de: 'Tabelle', en: 'Table' },
description: { de: 'Fügt eine Markdown-Tabelle mit allen Datenpunkten ein', en: 'Inserts a markdown table with all data points' },
icon: Table,
description: { de: 'Markdown-Tabelle mit allen Datenpunkten', en: 'Markdown table with all data points' },
},
{
placeholder: '[DATENPUNKTE_LIST]',
label: { de: 'Liste', en: 'List' },
description: { de: 'Kommaseparierte Liste der Datenpunkt-Namen', en: 'Comma-separated list of data point names' },
icon: List,
description: { de: 'Kommaseparierte Liste der Namen', en: 'Comma-separated list of names' },
},
{
placeholder: '[VERARBEITUNGSZWECKE]',
label: { de: 'Zwecke', en: 'Purposes' },
description: { de: 'Alle Verarbeitungszwecke (dedupliziert)', en: 'All processing purposes (deduplicated)' },
icon: FileText,
description: { de: 'Alle Verarbeitungszwecke', en: 'All processing purposes' },
},
{
placeholder: '[RECHTSGRUNDLAGEN]',
label: { de: 'Rechtsgrundlagen', en: 'Legal Bases' },
description: { de: 'Verwendete DSGVO-Artikel', en: 'Used GDPR articles' },
icon: Shield,
description: { de: 'DSGVO-Artikel', en: 'GDPR articles' },
},
{
placeholder: '[SPEICHERFRISTEN]',
label: { de: 'Speicherfristen', en: 'Retention' },
description: { de: 'Fristen gruppiert nach Kategorie', en: 'Periods grouped by category' },
icon: Clock,
description: { de: 'Fristen nach Kategorie', en: 'Periods by category' },
},
{
placeholder: '[EMPFAENGER]',
label: { de: 'Empfänger', en: 'Recipients' },
description: { de: 'Liste aller Drittparteien', en: 'List of all third parties' },
icon: Users,
description: { de: 'Liste aller Drittparteien', en: 'List of third parties' },
},
{
placeholder: '[BESONDERE_KATEGORIEN]',
label: { de: 'Art. 9 Abschnitt', en: 'Art. 9 Section' },
description: { de: 'DSGVO-konformer Abschnitt für sensible Daten', en: 'GDPR-compliant section for sensitive data' },
icon: AlertTriangle,
label: { de: 'Art. 9', en: 'Art. 9' },
description: { de: 'Abschnitt für sensible Daten', en: 'Section for sensitive data' },
},
{
placeholder: '[DRITTLAND_TRANSFERS]',
label: { de: 'Drittländer', en: 'Third Countries' },
description: { de: 'Abschnitt zu Datenübermittlung außerhalb EU', en: 'Section about data transfers outside EU' },
icon: Globe,
description: { de: 'Datenübermittlung außerhalb EU', en: 'Data transfers outside EU' },
},
]
/**
* Risiko-Badge Varianten mapping
* Risiko-Badge Farben
*/
function getRiskBadgeVariant(riskLevel: RiskLevel): 'default' | 'secondary' | 'destructive' | 'outline' {
function getRiskBadgeColor(riskLevel: RiskLevel): string {
switch (riskLevel) {
case 'HIGH':
return 'destructive'
return 'bg-red-100 text-red-700 border-red-200'
case 'MEDIUM':
return 'secondary'
return 'bg-yellow-100 text-yellow-700 border-yellow-200'
case 'LOW':
default:
return 'outline'
return 'bg-green-100 text-green-700 border-green-200'
}
}
/**
* DataPointsPreview Komponente
*
* Zeigt eine Vorschau der ausgewählten Einwilligungen-Datenpunkte im Dokumentengenerator.
* Ermöglicht das schnelle Einfügen von Platzhaltern.
*/
export function DataPointsPreview({
dataPoints,
onInsertPlaceholder,
language = 'de',
}: DataPointsPreviewProps) {
const [expandedCategories, setExpandedCategories] = useState<string[]>([])
// Gruppiere Datenpunkte nach Kategorie
const byCategory = useMemo(() => {
return dataPoints.reduce((acc, dp) => {
@@ -144,21 +101,15 @@ export function DataPointsPreview({
const stats = useMemo(() => {
const riskCounts: Record<RiskLevel, number> = { LOW: 0, MEDIUM: 0, HIGH: 0 }
let specialCategoryCount = 0
let explicitConsentCount = 0
const recipients = new Set<string>()
dataPoints.forEach(dp => {
riskCounts[dp.riskLevel]++
if (dp.isSpecialCategory) specialCategoryCount++
if (dp.requiresExplicitConsent) explicitConsentCount++
dp.thirdPartyRecipients?.forEach(r => recipients.add(r))
})
return {
riskCounts,
specialCategoryCount,
explicitConsentCount,
recipientCount: recipients.size,
categoryCount: Object.keys(byCategory).length,
}
}, [dataPoints, byCategory])
@@ -172,195 +123,173 @@ export function DataPointsPreview({
})
}, [byCategory])
const toggleCategory = (category: string) => {
setExpandedCategories(prev =>
prev.includes(category)
? prev.filter(c => c !== category)
: [...prev, category]
)
}
if (dataPoints.length === 0) {
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Database className="h-4 w-4" />
{language === 'de' ? 'Einwilligungen' : 'Consents'}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{language === 'de'
? 'Keine Datenpunkte ausgewählt. Wählen Sie Datenpunkte im Einwilligungs-Schritt aus, um sie hier zu sehen.'
: 'No data points selected. Select data points in the consent step to see them here.'}
</p>
</CardContent>
</Card>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 flex items-center gap-2 mb-3">
<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="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
{language === 'de' ? 'Einwilligungen' : 'Consents'}
</h4>
<p className="text-sm text-gray-500">
{language === 'de'
? 'Keine Datenpunkte ausgewählt. Wählen Sie Datenpunkte im Einwilligungs-Schritt aus.'
: 'No data points selected. Select data points in the consent step.'}
</p>
</div>
)
}
return (
<Card className="h-full flex flex-col">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Database className="h-4 w-4" />
<div className="bg-white rounded-xl border border-gray-200 p-6 h-full flex flex-col">
{/* Header */}
<div className="mb-4">
<h4 className="font-semibold text-gray-900 flex items-center gap-2">
<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="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
{language === 'de' ? 'Einwilligungen' : 'Consents'}
</CardTitle>
<CardDescription>
</h4>
<p className="text-sm text-gray-500 mt-1">
{dataPoints.length} {language === 'de' ? 'Datenpunkte aus' : 'data points from'}{' '}
{stats.categoryCount} {language === 'de' ? 'Kategorien' : 'categories'}
</CardDescription>
</CardHeader>
</p>
</div>
<CardContent className="flex-1 flex flex-col gap-4 overflow-hidden">
{/* Statistik-Badges */}
<div className="flex flex-wrap gap-2">
{stats.riskCounts.HIGH > 0 && (
<Badge variant="destructive">
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
</Badge>
)}
{stats.riskCounts.MEDIUM > 0 && (
<Badge variant="secondary">
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
</Badge>
)}
{stats.riskCounts.LOW > 0 && (
<Badge variant="outline">
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
</Badge>
)}
{stats.specialCategoryCount > 0 && (
<Badge variant="destructive" className="bg-orange-500 hover:bg-orange-600">
<AlertTriangle className="h-3 w-3 mr-1" />
{stats.specialCategoryCount} Art. 9
</Badge>
)}
</div>
{/* Statistik-Badges */}
<div className="flex flex-wrap gap-2 mb-4">
{stats.riskCounts.HIGH > 0 && (
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
</span>
)}
{stats.riskCounts.MEDIUM > 0 && (
<span className="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-700">
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
</span>
)}
{stats.riskCounts.LOW > 0 && (
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
</span>
)}
{stats.specialCategoryCount > 0 && (
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-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="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>
{stats.specialCategoryCount} Art. 9
</span>
)}
</div>
<Separator />
<div className="border-t border-gray-200 my-3"></div>
{/* Datenpunkte nach Kategorie */}
<ScrollArea className="flex-1 -mr-4 pr-4">
<Accordion type="multiple" className="w-full">
{sortedCategories.map(([category, points]) => {
const metadata = CATEGORY_METADATA[category as DataPointCategory]
if (!metadata) return null
{/* Datenpunkte nach Kategorie */}
<div className="flex-1 overflow-y-auto space-y-2 max-h-64">
{sortedCategories.map(([category, points]) => {
const metadata = CATEGORY_METADATA[category as DataPointCategory]
if (!metadata) return null
const isExpanded = expandedCategories.includes(category)
return (
<AccordionItem key={category} value={category}>
<AccordionTrigger className="text-sm hover:no-underline">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">
{metadata.code}
</span>
<span>
{language === 'de' ? metadata.name.de : metadata.name.en}
</span>
<Badge variant="secondary" className="ml-auto mr-2">
{points.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent>
<ul className="space-y-1 pl-6">
{points.map(dp => (
<li
key={dp.id}
className="flex items-center justify-between text-sm py-1"
>
<span className="truncate max-w-[180px]">
{language === 'de' ? dp.name.de : dp.name.en}
</span>
<div className="flex items-center gap-1">
{dp.isSpecialCategory && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<AlertTriangle className="h-3 w-3 text-orange-500" />
</TooltipTrigger>
<TooltipContent>
{language === 'de'
? 'Besondere Kategorie (Art. 9 DSGVO)'
: 'Special Category (Art. 9 GDPR)'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Badge
variant={getRiskBadgeVariant(dp.riskLevel)}
className="text-xs px-1.5 py-0"
>
{RISK_LEVEL_STYLING[dp.riskLevel].label[language]}
</Badge>
</div>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</ScrollArea>
return (
<div key={category} className="border border-gray-100 rounded-lg">
<button
onClick={() => toggleCategory(category)}
className="w-full flex items-center justify-between p-2 text-sm hover:bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-400">{metadata.code}</span>
<span className="font-medium text-gray-900">
{language === 'de' ? metadata.name.de : metadata.name.en}
</span>
</div>
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-600">
{points.length}
</span>
<svg
className={`w-4 h-4 text-gray-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>
</div>
</button>
<Separator />
{/* Schnell-Einfügen Buttons */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
</p>
<div className="flex flex-wrap gap-1.5">
<TooltipProvider>
{PLACEHOLDERS.slice(0, 4).map(({ placeholder, label, description, icon: Icon }) => (
<Tooltip key={placeholder}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => onInsertPlaceholder(placeholder)}
{isExpanded && (
<ul className="px-2 pb-2 space-y-1">
{points.map(dp => (
<li
key={dp.id}
className="flex items-center justify-between text-sm py-1 pl-6"
>
<Icon className="h-3 w-3 mr-1" />
{language === 'de' ? label.de : label.en}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">
{language === 'de' ? description.de : description.en}
</p>
<p className="text-xs text-muted-foreground font-mono mt-1">
{placeholder}
</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-1.5">
<TooltipProvider>
{PLACEHOLDERS.slice(4).map(({ placeholder, label, description, icon: Icon }) => (
<Tooltip key={placeholder}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => onInsertPlaceholder(placeholder)}
>
<Icon className="h-3 w-3 mr-1" />
{language === 'de' ? label.de : label.en}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">
{language === 'de' ? description.de : description.en}
</p>
<p className="text-xs text-muted-foreground font-mono mt-1">
{placeholder}
</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
<span className="truncate max-w-[160px] text-gray-700">
{language === 'de' ? dp.name.de : dp.name.en}
</span>
<div className="flex items-center gap-1">
{dp.isSpecialCategory && (
<svg className="w-3 h-3 text-orange-500" 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>
)}
<span className={`px-1.5 py-0.5 text-[10px] rounded border ${getRiskBadgeColor(dp.riskLevel)}`}>
{RISK_LEVEL_STYLING[dp.riskLevel].label[language]}
</span>
</div>
</li>
))}
</ul>
)}
</div>
)
})}
</div>
<div className="border-t border-gray-200 my-3"></div>
{/* Schnell-Einfügen Buttons */}
<div>
<p className="text-xs font-medium text-gray-500 mb-2">
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
</p>
<div className="flex flex-wrap gap-1.5">
{PLACEHOLDERS.slice(0, 4).map(({ placeholder, label }) => (
<button
key={placeholder}
onClick={() => onInsertPlaceholder(placeholder)}
className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
title={placeholder}
>
{language === 'de' ? label.de : label.en}
</button>
))}
</div>
</CardContent>
</Card>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{PLACEHOLDERS.slice(4).map(({ placeholder, label }) => (
<button
key={placeholder}
onClick={() => onInsertPlaceholder(placeholder)}
className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
title={placeholder}
>
{language === 'de' ? label.de : label.en}
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -1,22 +1,6 @@
'use client'
import { useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
AlertCircle,
AlertTriangle,
Info,
ChevronDown,
Lightbulb,
Plus,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { DataPoint } from '@/lib/sdk/einwilligungen/types'
import {
validateDocument,
@@ -30,28 +14,6 @@ interface DocumentValidationProps {
onInsertPlaceholder?: (placeholder: string) => void
}
/**
* Icon für den Warnungstyp
*/
function getWarningIcon(type: ValidationWarning['type']) {
switch (type) {
case 'error':
return AlertCircle
case 'warning':
return AlertTriangle
case 'info':
default:
return Info
}
}
/**
* Alert-Variante für den Warnungstyp
*/
function getAlertVariant(type: ValidationWarning['type']): 'default' | 'destructive' {
return type === 'error' ? 'destructive' : 'default'
}
/**
* Placeholder-Vorschlag aus der Warnung extrahieren
*/
@@ -62,9 +24,6 @@ function extractPlaceholderSuggestion(warning: ValidationWarning): string | null
/**
* DocumentValidation Komponente
*
* Zeigt Validierungswarnungen basierend auf ausgewählten Datenpunkten und
* dem generierten Dokumentinhalt.
*/
export function DocumentValidation({
dataPoints,
@@ -72,6 +31,8 @@ export function DocumentValidation({
language = 'de',
onInsertPlaceholder,
}: DocumentValidationProps) {
const [expandedWarnings, setExpandedWarnings] = useState<string[]>([])
// Führe Validierung durch
const warnings = useMemo(() => {
if (dataPoints.length === 0 || !documentContent) {
@@ -85,21 +46,33 @@ export function DocumentValidation({
const warningCount = warnings.filter(w => w.type === 'warning').length
const infoCount = warnings.filter(w => w.type === 'info').length
const toggleWarning = (code: string) => {
setExpandedWarnings(prev =>
prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]
)
}
if (warnings.length === 0) {
// Keine Warnungen - zeige Erfolgsmeldung wenn Datenpunkte vorhanden
if (dataPoints.length > 0 && documentContent.length > 100) {
return (
<Alert className="bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-900">
<Info className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertTitle className="text-green-800 dark:text-green-200">
{language === 'de' ? 'Dokument valide' : 'Document valid'}
</AlertTitle>
<AlertDescription className="text-green-700 dark:text-green-300">
{language === 'de'
? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.'
: 'All necessary sections for the selected data points are present.'}
</AlertDescription>
</Alert>
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-green-600 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>
<div>
<h4 className="font-medium text-green-800">
{language === 'de' ? 'Dokument valide' : 'Document valid'}
</h4>
<p className="text-sm text-green-700 mt-1">
{language === 'de'
? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.'
: 'All necessary sections for the selected data points are present.'}
</p>
</div>
</div>
</div>
)
}
return null
@@ -109,91 +82,126 @@ export function DocumentValidation({
<div className="space-y-3">
{/* Zusammenfassung */}
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">
<span className="font-medium text-gray-700">
{language === 'de' ? 'Validierung:' : 'Validation:'}
</span>
{errorCount > 0 && (
<Badge variant="destructive">
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}
{errorCount > 1 && (language === 'de' ? '' : 's')}
</Badge>
<span className="px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-700">
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}{errorCount > 1 && 's'}
</span>
)}
{warningCount > 0 && (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}
{warningCount > 1 && (language === 'de' ? 'en' : 's')}
</Badge>
<span className="px-2 py-0.5 text-xs rounded-full bg-yellow-100 text-yellow-700">
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}{warningCount > 1 && (language === 'de' ? 'en' : 's')}
</span>
)}
{infoCount > 0 && (
<Badge variant="outline">
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}
{infoCount > 1 && (language === 'de' ? 'e' : 's')}
</Badge>
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}{infoCount > 1 && (language === 'de' ? 'e' : 's')}
</span>
)}
</div>
{/* Warnungen */}
{warnings.map((warning, index) => {
const Icon = getWarningIcon(warning.type)
const placeholder = extractPlaceholderSuggestion(warning)
const isExpanded = expandedWarnings.includes(warning.code)
const isError = warning.type === 'error'
return (
<Alert key={`${warning.code}-${index}`} variant={getAlertVariant(warning.type)}>
<Icon className="h-4 w-4" />
<AlertTitle className="flex items-center justify-between">
<span>{warning.message}</span>
</AlertTitle>
<AlertDescription className="mt-2 space-y-2">
{/* Vorschlag */}
<div className="flex items-start gap-2">
<Lightbulb className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span className="text-sm">{warning.suggestion}</span>
<div
key={`${warning.code}-${index}`}
className={`rounded-xl border p-4 ${
isError
? 'bg-red-50 border-red-200'
: 'bg-yellow-50 border-yellow-200'
}`}
>
<div className="flex items-start gap-3">
{/* Icon */}
<svg
className={`w-5 h-5 mt-0.5 ${isError ? 'text-red-600' : 'text-yellow-600'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{isError ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 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>
<div className="flex-1">
{/* Message */}
<p className={`font-medium ${isError ? 'text-red-800' : 'text-yellow-800'}`}>
{warning.message}
</p>
{/* Suggestion */}
<div className="flex items-start gap-2 mt-2">
<svg className="w-4 h-4 mt-0.5 text-gray-500" 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>
<span className="text-sm text-gray-600">{warning.suggestion}</span>
</div>
{/* Quick-Fix Button */}
{placeholder && onInsertPlaceholder && (
<button
onClick={() => onInsertPlaceholder(placeholder)}
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:bg-gray-50 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>
{language === 'de' ? 'Platzhalter einfügen' : 'Insert placeholder'}
<code className="ml-1 text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{placeholder}
</code>
</button>
)}
{/* Betroffene Datenpunkte */}
{warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && (
<div className="mt-3">
<button
onClick={() => toggleWarning(warning.code)}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700"
>
<svg
className={`w-3 h-3 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>
{warning.affectedDataPoints.length}{' '}
{language === 'de' ? 'betroffene Datenpunkte' : 'affected data points'}
</button>
{isExpanded && (
<ul className="mt-2 text-xs space-y-0.5 pl-4">
{warning.affectedDataPoints.slice(0, 5).map(dp => (
<li key={dp.id} className="list-disc text-gray-600">
{language === 'de' ? dp.name.de : dp.name.en}
</li>
))}
{warning.affectedDataPoints.length > 5 && (
<li className="list-none text-gray-400">
... {language === 'de' ? 'und' : 'and'}{' '}
{warning.affectedDataPoints.length - 5}{' '}
{language === 'de' ? 'weitere' : 'more'}
</li>
)}
</ul>
)}
</div>
)}
</div>
{/* Quick-Fix Button */}
{placeholder && onInsertPlaceholder && (
<Button
size="sm"
variant="outline"
className="mt-2"
onClick={() => onInsertPlaceholder(placeholder)}
>
<Plus className="h-3 w-3 mr-1" />
{language === 'de' ? 'Platzhalter einfügen' : 'Insert placeholder'}
<code className="ml-1 text-xs bg-muted px-1 rounded">
{placeholder}
</code>
</Button>
)}
{/* Betroffene Datenpunkte */}
{warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && (
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
<ChevronDown className="h-3 w-3" />
{warning.affectedDataPoints.length}{' '}
{language === 'de' ? 'betroffene Datenpunkte' : 'affected data points'}
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<ul className="text-xs space-y-0.5 pl-4">
{warning.affectedDataPoints.slice(0, 5).map(dp => (
<li key={dp.id} className="list-disc">
{language === 'de' ? dp.name.de : dp.name.en}
</li>
))}
{warning.affectedDataPoints.length > 5 && (
<li className="list-none text-muted-foreground">
... {language === 'de' ? 'und' : 'and'}{' '}
{warning.affectedDataPoints.length - 5}{' '}
{language === 'de' ? 'weitere' : 'more'}
</li>
)}
</ul>
</CollapsibleContent>
</Collapsible>
)}
</AlertDescription>
</Alert>
</div>
</div>
)
})}
</div>

View File

@@ -0,0 +1,191 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
export interface UploadedFile {
id: string
sessionId: string
name: string
type: string
size: number
uploadedAt: string
dataUrl: string
}
interface QRCodeUploadProps {
sessionId?: string
onClose?: () => void
onFileUploaded?: (file: UploadedFile) => void
onFilesChanged?: (files: UploadedFile[]) => void
className?: string
}
export function QRCodeUpload({
sessionId,
onClose,
onFileUploaded,
onFilesChanged,
className = ''
}: QRCodeUploadProps) {
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null)
const [uploadUrl, setUploadUrl] = useState<string>('')
const [isLoading, setIsLoading] = useState(true)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [isPolling, setIsPolling] = useState(false)
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const fetchUploads = useCallback(async () => {
if (!sessionId) return
try {
const response = await fetch(`/api/uploads?sessionId=${sessionId}`)
if (response.ok) {
const data = await response.json()
const newFiles = data.uploads || []
if (newFiles.length > uploadedFiles.length) {
const newlyAdded = newFiles.slice(uploadedFiles.length)
newlyAdded.forEach((file: UploadedFile) => {
if (onFileUploaded) onFileUploaded(file)
})
}
setUploadedFiles(newFiles)
if (onFilesChanged) onFilesChanged(newFiles)
}
} catch (error) {
console.error('Failed to fetch uploads:', error)
}
}, [sessionId, uploadedFiles.length, onFileUploaded, onFilesChanged])
useEffect(() => {
let baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
const hostnameToIP: Record<string, string> = {
'macmini': '192.168.178.100',
'macmini.local': '192.168.178.100',
}
Object.entries(hostnameToIP).forEach(([hostname, ip]) => {
if (baseUrl.includes(hostname)) baseUrl = baseUrl.replace(hostname, ip)
})
const uploadPath = `/upload/${sessionId || 'new'}`
const fullUrl = `${baseUrl}${uploadPath}`
setUploadUrl(fullUrl)
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(fullUrl)}`
setQrCodeUrl(qrApiUrl)
setIsLoading(false)
fetchUploads()
setIsPolling(true)
const pollInterval = setInterval(() => fetchUploads(), 3000)
return () => { clearInterval(pollInterval); setIsPolling(false) }
}, [sessionId])
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(uploadUrl)
alert('Link kopiert!')
} catch (err) {
console.error('Kopieren fehlgeschlagen:', err)
}
}
const deleteUpload = async (id: string) => {
try {
const response = await fetch(`/api/uploads?id=${id}`, { method: 'DELETE' })
if (response.ok) {
const newFiles = uploadedFiles.filter(f => f.id !== id)
setUploadedFiles(newFiles)
if (onFilesChanged) onFilesChanged(newFiles)
}
} catch (error) {
console.error('Failed to delete upload:', error)
}
}
return (
<div className={className}>
<div className="rounded-3xl border bg-white border-slate-200 shadow-lg p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-purple-100">
<span className="text-xl">📱</span>
</div>
<div>
<h3 className="font-semibold text-slate-900">Mit Mobiltelefon hochladen</h3>
<p className="text-sm text-slate-500">QR-Code scannen oder Link teilen</p>
</div>
</div>
{onClose && (
<button onClick={onClose} className="p-2 rounded-lg transition-colors hover:bg-slate-100 text-slate-400">
<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="flex flex-col items-center">
<div className="p-4 rounded-2xl bg-slate-50">
{isLoading ? (
<div className="w-[200px] h-[200px] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
</div>
) : qrCodeUrl ? (
<img src={qrCodeUrl} alt="QR Code zum Hochladen" className="w-[200px] h-[200px]" />
) : (
<div className="w-[200px] h-[200px] flex items-center justify-center text-slate-400">
QR-Code nicht verfuegbar
</div>
)}
</div>
<p className="mt-4 text-center text-sm text-slate-600">
Scannen Sie diesen Code mit Ihrem Handy,<br />um Dokumente direkt hochzuladen.
</p>
{isPolling && (
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Warte auf Uploads...
</div>
)}
</div>
{uploadedFiles.length > 0 && (
<div className="mt-6 p-4 rounded-xl bg-green-50 border border-green-200">
<p className="text-sm font-medium text-green-700 mb-3">
{uploadedFiles.length} Datei{uploadedFiles.length !== 1 ? 'en' : ''} empfangen
</p>
<div className="space-y-2 max-h-40 overflow-y-auto">
{uploadedFiles.map((file) => (
<div key={file.id} className="flex items-center gap-3 p-2 rounded-lg bg-white">
<span className="text-lg">{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate text-slate-900">{file.name}</p>
<p className="text-xs text-slate-500">{formatFileSize(file.size)}</p>
</div>
<button onClick={() => deleteUpload(file.id)} className="p-1 rounded transition-colors hover:bg-red-100 text-red-500">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
<div className="mt-6">
<p className="text-xs mb-2 text-slate-400">Oder Link teilen:</p>
<div className="flex items-center gap-2">
<input type="text" value={uploadUrl} readOnly className="flex-1 px-3 py-2 rounded-xl text-sm border bg-slate-50 border-slate-200 text-slate-700" />
<button onClick={copyToClipboard} className="px-4 py-2 rounded-xl text-sm font-medium transition-colors bg-slate-200 text-slate-700 hover:bg-slate-300">
Kopieren
</button>
</div>
</div>
</div>
</div>
)
}