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:
@@ -1,40 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useState } 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 {
|
import {
|
||||||
DataPoint,
|
DataPoint,
|
||||||
DataPointCategory,
|
DataPointCategory,
|
||||||
@@ -56,79 +22,70 @@ const PLACEHOLDERS = [
|
|||||||
{
|
{
|
||||||
placeholder: '[DATENPUNKTE_TABLE]',
|
placeholder: '[DATENPUNKTE_TABLE]',
|
||||||
label: { de: 'Tabelle', en: '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' },
|
description: { de: 'Markdown-Tabelle mit allen Datenpunkten', en: 'Markdown table with all data points' },
|
||||||
icon: Table,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholder: '[DATENPUNKTE_LIST]',
|
placeholder: '[DATENPUNKTE_LIST]',
|
||||||
label: { de: 'Liste', en: 'List' },
|
label: { de: 'Liste', en: 'List' },
|
||||||
description: { de: 'Kommaseparierte Liste der Datenpunkt-Namen', en: 'Comma-separated list of data point names' },
|
description: { de: 'Kommaseparierte Liste der Namen', en: 'Comma-separated list of names' },
|
||||||
icon: List,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholder: '[VERARBEITUNGSZWECKE]',
|
placeholder: '[VERARBEITUNGSZWECKE]',
|
||||||
label: { de: 'Zwecke', en: 'Purposes' },
|
label: { de: 'Zwecke', en: 'Purposes' },
|
||||||
description: { de: 'Alle Verarbeitungszwecke (dedupliziert)', en: 'All processing purposes (deduplicated)' },
|
description: { de: 'Alle Verarbeitungszwecke', en: 'All processing purposes' },
|
||||||
icon: FileText,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholder: '[RECHTSGRUNDLAGEN]',
|
placeholder: '[RECHTSGRUNDLAGEN]',
|
||||||
label: { de: 'Rechtsgrundlagen', en: 'Legal Bases' },
|
label: { de: 'Rechtsgrundlagen', en: 'Legal Bases' },
|
||||||
description: { de: 'Verwendete DSGVO-Artikel', en: 'Used GDPR articles' },
|
description: { de: 'DSGVO-Artikel', en: 'GDPR articles' },
|
||||||
icon: Shield,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholder: '[SPEICHERFRISTEN]',
|
placeholder: '[SPEICHERFRISTEN]',
|
||||||
label: { de: 'Speicherfristen', en: 'Retention' },
|
label: { de: 'Speicherfristen', en: 'Retention' },
|
||||||
description: { de: 'Fristen gruppiert nach Kategorie', en: 'Periods grouped by category' },
|
description: { de: 'Fristen nach Kategorie', en: 'Periods by category' },
|
||||||
icon: Clock,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholder: '[EMPFAENGER]',
|
placeholder: '[EMPFAENGER]',
|
||||||
label: { de: 'Empfänger', en: 'Recipients' },
|
label: { de: 'Empfänger', en: 'Recipients' },
|
||||||
description: { de: 'Liste aller Drittparteien', en: 'List of all third parties' },
|
description: { de: 'Liste aller Drittparteien', en: 'List of third parties' },
|
||||||
icon: Users,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholder: '[BESONDERE_KATEGORIEN]',
|
placeholder: '[BESONDERE_KATEGORIEN]',
|
||||||
label: { de: 'Art. 9 Abschnitt', en: 'Art. 9 Section' },
|
label: { de: 'Art. 9', en: 'Art. 9' },
|
||||||
description: { de: 'DSGVO-konformer Abschnitt für sensible Daten', en: 'GDPR-compliant section for sensitive data' },
|
description: { de: 'Abschnitt für sensible Daten', en: 'Section for sensitive data' },
|
||||||
icon: AlertTriangle,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholder: '[DRITTLAND_TRANSFERS]',
|
placeholder: '[DRITTLAND_TRANSFERS]',
|
||||||
label: { de: 'Drittländer', en: 'Third Countries' },
|
label: { de: 'Drittländer', en: 'Third Countries' },
|
||||||
description: { de: 'Abschnitt zu Datenübermittlung außerhalb EU', en: 'Section about data transfers outside EU' },
|
description: { de: 'Datenübermittlung außerhalb EU', en: 'Data transfers outside EU' },
|
||||||
icon: Globe,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Risiko-Badge Varianten mapping
|
* Risiko-Badge Farben
|
||||||
*/
|
*/
|
||||||
function getRiskBadgeVariant(riskLevel: RiskLevel): 'default' | 'secondary' | 'destructive' | 'outline' {
|
function getRiskBadgeColor(riskLevel: RiskLevel): string {
|
||||||
switch (riskLevel) {
|
switch (riskLevel) {
|
||||||
case 'HIGH':
|
case 'HIGH':
|
||||||
return 'destructive'
|
return 'bg-red-100 text-red-700 border-red-200'
|
||||||
case 'MEDIUM':
|
case 'MEDIUM':
|
||||||
return 'secondary'
|
return 'bg-yellow-100 text-yellow-700 border-yellow-200'
|
||||||
case 'LOW':
|
case 'LOW':
|
||||||
default:
|
default:
|
||||||
return 'outline'
|
return 'bg-green-100 text-green-700 border-green-200'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DataPointsPreview Komponente
|
* DataPointsPreview Komponente
|
||||||
*
|
|
||||||
* Zeigt eine Vorschau der ausgewählten Einwilligungen-Datenpunkte im Dokumentengenerator.
|
|
||||||
* Ermöglicht das schnelle Einfügen von Platzhaltern.
|
|
||||||
*/
|
*/
|
||||||
export function DataPointsPreview({
|
export function DataPointsPreview({
|
||||||
dataPoints,
|
dataPoints,
|
||||||
onInsertPlaceholder,
|
onInsertPlaceholder,
|
||||||
language = 'de',
|
language = 'de',
|
||||||
}: DataPointsPreviewProps) {
|
}: DataPointsPreviewProps) {
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<string[]>([])
|
||||||
|
|
||||||
// Gruppiere Datenpunkte nach Kategorie
|
// Gruppiere Datenpunkte nach Kategorie
|
||||||
const byCategory = useMemo(() => {
|
const byCategory = useMemo(() => {
|
||||||
return dataPoints.reduce((acc, dp) => {
|
return dataPoints.reduce((acc, dp) => {
|
||||||
@@ -144,21 +101,15 @@ export function DataPointsPreview({
|
|||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const riskCounts: Record<RiskLevel, number> = { LOW: 0, MEDIUM: 0, HIGH: 0 }
|
const riskCounts: Record<RiskLevel, number> = { LOW: 0, MEDIUM: 0, HIGH: 0 }
|
||||||
let specialCategoryCount = 0
|
let specialCategoryCount = 0
|
||||||
let explicitConsentCount = 0
|
|
||||||
const recipients = new Set<string>()
|
|
||||||
|
|
||||||
dataPoints.forEach(dp => {
|
dataPoints.forEach(dp => {
|
||||||
riskCounts[dp.riskLevel]++
|
riskCounts[dp.riskLevel]++
|
||||||
if (dp.isSpecialCategory) specialCategoryCount++
|
if (dp.isSpecialCategory) specialCategoryCount++
|
||||||
if (dp.requiresExplicitConsent) explicitConsentCount++
|
|
||||||
dp.thirdPartyRecipients?.forEach(r => recipients.add(r))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
riskCounts,
|
riskCounts,
|
||||||
specialCategoryCount,
|
specialCategoryCount,
|
||||||
explicitConsentCount,
|
|
||||||
recipientCount: recipients.size,
|
|
||||||
categoryCount: Object.keys(byCategory).length,
|
categoryCount: Object.keys(byCategory).length,
|
||||||
}
|
}
|
||||||
}, [dataPoints, byCategory])
|
}, [dataPoints, byCategory])
|
||||||
@@ -172,195 +123,173 @@ export function DataPointsPreview({
|
|||||||
})
|
})
|
||||||
}, [byCategory])
|
}, [byCategory])
|
||||||
|
|
||||||
|
const toggleCategory = (category: string) => {
|
||||||
|
setExpandedCategories(prev =>
|
||||||
|
prev.includes(category)
|
||||||
|
? prev.filter(c => c !== category)
|
||||||
|
: [...prev, category]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (dataPoints.length === 0) {
|
if (dataPoints.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<CardHeader className="pb-3">
|
<h4 className="font-semibold text-gray-900 flex items-center gap-2 mb-3">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<Database className="h-4 w-4" />
|
<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" />
|
||||||
{language === 'de' ? 'Einwilligungen' : 'Consents'}
|
</svg>
|
||||||
</CardTitle>
|
{language === 'de' ? 'Einwilligungen' : 'Consents'}
|
||||||
</CardHeader>
|
</h4>
|
||||||
<CardContent>
|
<p className="text-sm text-gray-500">
|
||||||
<p className="text-sm text-muted-foreground">
|
{language === 'de'
|
||||||
{language === 'de'
|
? 'Keine Datenpunkte ausgewählt. Wählen Sie Datenpunkte im Einwilligungs-Schritt aus.'
|
||||||
? '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.'}
|
||||||
: 'No data points selected. Select data points in the consent step to see them here.'}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full flex flex-col">
|
<div className="bg-white rounded-xl border border-gray-200 p-6 h-full flex flex-col">
|
||||||
<CardHeader className="pb-3">
|
{/* Header */}
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<div className="mb-4">
|
||||||
<Database className="h-4 w-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'}
|
{language === 'de' ? 'Einwilligungen' : 'Consents'}
|
||||||
</CardTitle>
|
</h4>
|
||||||
<CardDescription>
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
{dataPoints.length} {language === 'de' ? 'Datenpunkte aus' : 'data points from'}{' '}
|
{dataPoints.length} {language === 'de' ? 'Datenpunkte aus' : 'data points from'}{' '}
|
||||||
{stats.categoryCount} {language === 'de' ? 'Kategorien' : 'categories'}
|
{stats.categoryCount} {language === 'de' ? 'Kategorien' : 'categories'}
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
|
|
||||||
<CardContent className="flex-1 flex flex-col gap-4 overflow-hidden">
|
{/* Statistik-Badges */}
|
||||||
{/* Statistik-Badges */}
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
{stats.riskCounts.HIGH > 0 && (
|
||||||
{stats.riskCounts.HIGH > 0 && (
|
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">
|
||||||
<Badge variant="destructive">
|
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
|
||||||
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
|
</span>
|
||||||
</Badge>
|
)}
|
||||||
)}
|
{stats.riskCounts.MEDIUM > 0 && (
|
||||||
{stats.riskCounts.MEDIUM > 0 && (
|
<span className="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-700">
|
||||||
<Badge variant="secondary">
|
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
|
||||||
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
|
</span>
|
||||||
</Badge>
|
)}
|
||||||
)}
|
{stats.riskCounts.LOW > 0 && (
|
||||||
{stats.riskCounts.LOW > 0 && (
|
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">
|
||||||
<Badge variant="outline">
|
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
|
||||||
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
|
</span>
|
||||||
</Badge>
|
)}
|
||||||
)}
|
{stats.specialCategoryCount > 0 && (
|
||||||
{stats.specialCategoryCount > 0 && (
|
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700 flex items-center gap-1">
|
||||||
<Badge variant="destructive" className="bg-orange-500 hover:bg-orange-600">
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
<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" />
|
||||||
{stats.specialCategoryCount} Art. 9
|
</svg>
|
||||||
</Badge>
|
{stats.specialCategoryCount} Art. 9
|
||||||
)}
|
</span>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<div className="border-t border-gray-200 my-3"></div>
|
||||||
|
|
||||||
{/* Datenpunkte nach Kategorie */}
|
{/* Datenpunkte nach Kategorie */}
|
||||||
<ScrollArea className="flex-1 -mr-4 pr-4">
|
<div className="flex-1 overflow-y-auto space-y-2 max-h-64">
|
||||||
<Accordion type="multiple" className="w-full">
|
{sortedCategories.map(([category, points]) => {
|
||||||
{sortedCategories.map(([category, points]) => {
|
const metadata = CATEGORY_METADATA[category as DataPointCategory]
|
||||||
const metadata = CATEGORY_METADATA[category as DataPointCategory]
|
if (!metadata) return null
|
||||||
if (!metadata) return null
|
const isExpanded = expandedCategories.includes(category)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem key={category} value={category}>
|
<div key={category} className="border border-gray-100 rounded-lg">
|
||||||
<AccordionTrigger className="text-sm hover:no-underline">
|
<button
|
||||||
<div className="flex items-center gap-2">
|
onClick={() => toggleCategory(category)}
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
className="w-full flex items-center justify-between p-2 text-sm hover:bg-gray-50 rounded-lg"
|
||||||
{metadata.code}
|
>
|
||||||
</span>
|
<div className="flex items-center gap-2">
|
||||||
<span>
|
<span className="font-mono text-xs text-gray-400">{metadata.code}</span>
|
||||||
{language === 'de' ? metadata.name.de : metadata.name.en}
|
<span className="font-medium text-gray-900">
|
||||||
</span>
|
{language === 'de' ? metadata.name.de : metadata.name.en}
|
||||||
<Badge variant="secondary" className="ml-auto mr-2">
|
</span>
|
||||||
{points.length}
|
</div>
|
||||||
</Badge>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<span className="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-600">
|
||||||
</AccordionTrigger>
|
{points.length}
|
||||||
<AccordionContent>
|
</span>
|
||||||
<ul className="space-y-1 pl-6">
|
<svg
|
||||||
{points.map(dp => (
|
className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
<li
|
fill="none"
|
||||||
key={dp.id}
|
stroke="currentColor"
|
||||||
className="flex items-center justify-between text-sm py-1"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<span className="truncate max-w-[180px]">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
{language === 'de' ? dp.name.de : dp.name.en}
|
</svg>
|
||||||
</span>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
</button>
|
||||||
{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>
|
|
||||||
|
|
||||||
<Separator />
|
{isExpanded && (
|
||||||
|
<ul className="px-2 pb-2 space-y-1">
|
||||||
{/* Schnell-Einfügen Buttons */}
|
{points.map(dp => (
|
||||||
<div className="space-y-2">
|
<li
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
key={dp.id}
|
||||||
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
|
className="flex items-center justify-between text-sm py-1 pl-6"
|
||||||
</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)}
|
|
||||||
>
|
>
|
||||||
<Icon className="h-3 w-3 mr-1" />
|
<span className="truncate max-w-[160px] text-gray-700">
|
||||||
{language === 'de' ? label.de : label.en}
|
{language === 'de' ? dp.name.de : dp.name.en}
|
||||||
</Button>
|
</span>
|
||||||
</TooltipTrigger>
|
<div className="flex items-center gap-1">
|
||||||
<TooltipContent side="bottom">
|
{dp.isSpecialCategory && (
|
||||||
<p className="text-xs">
|
<svg className="w-3 h-3 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{language === 'de' ? description.de : description.en}
|
<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" />
|
||||||
</p>
|
</svg>
|
||||||
<p className="text-xs text-muted-foreground font-mono mt-1">
|
)}
|
||||||
{placeholder}
|
<span className={`px-1.5 py-0.5 text-[10px] rounded border ${getRiskBadgeColor(dp.riskLevel)}`}>
|
||||||
</p>
|
{RISK_LEVEL_STYLING[dp.riskLevel].label[language]}
|
||||||
</TooltipContent>
|
</span>
|
||||||
</Tooltip>
|
</div>
|
||||||
))}
|
</li>
|
||||||
</TooltipProvider>
|
))}
|
||||||
</div>
|
</ul>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
)}
|
||||||
<TooltipProvider>
|
</div>
|
||||||
{PLACEHOLDERS.slice(4).map(({ placeholder, label, description, icon: Icon }) => (
|
)
|
||||||
<Tooltip key={placeholder}>
|
})}
|
||||||
<TooltipTrigger asChild>
|
</div>
|
||||||
<Button
|
|
||||||
size="sm"
|
<div className="border-t border-gray-200 my-3"></div>
|
||||||
variant="outline"
|
|
||||||
className="h-7 text-xs px-2"
|
{/* Schnell-Einfügen Buttons */}
|
||||||
onClick={() => onInsertPlaceholder(placeholder)}
|
<div>
|
||||||
>
|
<p className="text-xs font-medium text-gray-500 mb-2">
|
||||||
<Icon className="h-3 w-3 mr-1" />
|
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
|
||||||
{language === 'de' ? label.de : label.en}
|
</p>
|
||||||
</Button>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
</TooltipTrigger>
|
{PLACEHOLDERS.slice(0, 4).map(({ placeholder, label }) => (
|
||||||
<TooltipContent side="bottom">
|
<button
|
||||||
<p className="text-xs">
|
key={placeholder}
|
||||||
{language === 'de' ? description.de : description.en}
|
onClick={() => onInsertPlaceholder(placeholder)}
|
||||||
</p>
|
className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
|
||||||
<p className="text-xs text-muted-foreground font-mono mt-1">
|
title={placeholder}
|
||||||
{placeholder}
|
>
|
||||||
</p>
|
{language === 'de' ? label.de : label.en}
|
||||||
</TooltipContent>
|
</button>
|
||||||
</Tooltip>
|
))}
|
||||||
))}
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||||
</Card>
|
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useState } 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 { DataPoint } from '@/lib/sdk/einwilligungen/types'
|
import { DataPoint } from '@/lib/sdk/einwilligungen/types'
|
||||||
import {
|
import {
|
||||||
validateDocument,
|
validateDocument,
|
||||||
@@ -30,28 +14,6 @@ interface DocumentValidationProps {
|
|||||||
onInsertPlaceholder?: (placeholder: string) => void
|
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
|
* Placeholder-Vorschlag aus der Warnung extrahieren
|
||||||
*/
|
*/
|
||||||
@@ -62,9 +24,6 @@ function extractPlaceholderSuggestion(warning: ValidationWarning): string | null
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* DocumentValidation Komponente
|
* DocumentValidation Komponente
|
||||||
*
|
|
||||||
* Zeigt Validierungswarnungen basierend auf ausgewählten Datenpunkten und
|
|
||||||
* dem generierten Dokumentinhalt.
|
|
||||||
*/
|
*/
|
||||||
export function DocumentValidation({
|
export function DocumentValidation({
|
||||||
dataPoints,
|
dataPoints,
|
||||||
@@ -72,6 +31,8 @@ export function DocumentValidation({
|
|||||||
language = 'de',
|
language = 'de',
|
||||||
onInsertPlaceholder,
|
onInsertPlaceholder,
|
||||||
}: DocumentValidationProps) {
|
}: DocumentValidationProps) {
|
||||||
|
const [expandedWarnings, setExpandedWarnings] = useState<string[]>([])
|
||||||
|
|
||||||
// Führe Validierung durch
|
// Führe Validierung durch
|
||||||
const warnings = useMemo(() => {
|
const warnings = useMemo(() => {
|
||||||
if (dataPoints.length === 0 || !documentContent) {
|
if (dataPoints.length === 0 || !documentContent) {
|
||||||
@@ -85,21 +46,33 @@ export function DocumentValidation({
|
|||||||
const warningCount = warnings.filter(w => w.type === 'warning').length
|
const warningCount = warnings.filter(w => w.type === 'warning').length
|
||||||
const infoCount = warnings.filter(w => w.type === 'info').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) {
|
if (warnings.length === 0) {
|
||||||
// Keine Warnungen - zeige Erfolgsmeldung wenn Datenpunkte vorhanden
|
// Keine Warnungen - zeige Erfolgsmeldung wenn Datenpunkte vorhanden
|
||||||
if (dataPoints.length > 0 && documentContent.length > 100) {
|
if (dataPoints.length > 0 && documentContent.length > 100) {
|
||||||
return (
|
return (
|
||||||
<Alert className="bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-900">
|
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||||
<Info className="h-4 w-4 text-green-600 dark:text-green-400" />
|
<div className="flex items-start gap-3">
|
||||||
<AlertTitle className="text-green-800 dark:text-green-200">
|
<svg className="w-5 h-5 text-green-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{language === 'de' ? 'Dokument valide' : 'Document valid'}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</AlertTitle>
|
</svg>
|
||||||
<AlertDescription className="text-green-700 dark:text-green-300">
|
<div>
|
||||||
{language === 'de'
|
<h4 className="font-medium text-green-800">
|
||||||
? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.'
|
{language === 'de' ? 'Dokument valide' : 'Document valid'}
|
||||||
: 'All necessary sections for the selected data points are present.'}
|
</h4>
|
||||||
</AlertDescription>
|
<p className="text-sm text-green-700 mt-1">
|
||||||
</Alert>
|
{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
|
return null
|
||||||
@@ -109,91 +82,126 @@ export function DocumentValidation({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Zusammenfassung */}
|
{/* Zusammenfassung */}
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="font-medium">
|
<span className="font-medium text-gray-700">
|
||||||
{language === 'de' ? 'Validierung:' : 'Validation:'}
|
{language === 'de' ? 'Validierung:' : 'Validation:'}
|
||||||
</span>
|
</span>
|
||||||
{errorCount > 0 && (
|
{errorCount > 0 && (
|
||||||
<Badge variant="destructive">
|
<span className="px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-700">
|
||||||
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}
|
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}{errorCount > 1 && 's'}
|
||||||
{errorCount > 1 && (language === 'de' ? '' : 's')}
|
</span>
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
{warningCount > 0 && (
|
{warningCount > 0 && (
|
||||||
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
<span className="px-2 py-0.5 text-xs rounded-full bg-yellow-100 text-yellow-700">
|
||||||
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}
|
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}{warningCount > 1 && (language === 'de' ? 'en' : 's')}
|
||||||
{warningCount > 1 && (language === 'de' ? 'en' : 's')}
|
</span>
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
{infoCount > 0 && (
|
{infoCount > 0 && (
|
||||||
<Badge variant="outline">
|
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">
|
||||||
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}
|
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}{infoCount > 1 && (language === 'de' ? 'e' : 's')}
|
||||||
{infoCount > 1 && (language === 'de' ? 'e' : 's')}
|
</span>
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Warnungen */}
|
{/* Warnungen */}
|
||||||
{warnings.map((warning, index) => {
|
{warnings.map((warning, index) => {
|
||||||
const Icon = getWarningIcon(warning.type)
|
|
||||||
const placeholder = extractPlaceholderSuggestion(warning)
|
const placeholder = extractPlaceholderSuggestion(warning)
|
||||||
|
const isExpanded = expandedWarnings.includes(warning.code)
|
||||||
|
const isError = warning.type === 'error'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert key={`${warning.code}-${index}`} variant={getAlertVariant(warning.type)}>
|
<div
|
||||||
<Icon className="h-4 w-4" />
|
key={`${warning.code}-${index}`}
|
||||||
<AlertTitle className="flex items-center justify-between">
|
className={`rounded-xl border p-4 ${
|
||||||
<span>{warning.message}</span>
|
isError
|
||||||
</AlertTitle>
|
? 'bg-red-50 border-red-200'
|
||||||
<AlertDescription className="mt-2 space-y-2">
|
: 'bg-yellow-50 border-yellow-200'
|
||||||
{/* Vorschlag */}
|
}`}
|
||||||
<div className="flex items-start gap-2">
|
>
|
||||||
<Lightbulb className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
<div className="flex items-start gap-3">
|
||||||
<span className="text-sm">{warning.suggestion}</span>
|
{/* 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>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Quick-Fix Button */}
|
</div>
|
||||||
{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>
|
||||||
|
|||||||
191
admin-v2/components/QRCodeUpload.tsx
Normal file
191
admin-v2/components/QRCodeUpload.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user