diff --git a/admin-v2/app/(sdk)/sdk/document-generator/components/DataPointsPreview.tsx b/admin-v2/app/(sdk)/sdk/document-generator/components/DataPointsPreview.tsx index 67d74c4..5737691 100644 --- a/admin-v2/app/(sdk)/sdk/document-generator/components/DataPointsPreview.tsx +++ b/admin-v2/app/(sdk)/sdk/document-generator/components/DataPointsPreview.tsx @@ -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([]) + // 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 = { LOW: 0, MEDIUM: 0, HIGH: 0 } let specialCategoryCount = 0 - let explicitConsentCount = 0 - const recipients = new Set() 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 ( - - - - - {language === 'de' ? 'Einwilligungen' : 'Consents'} - - - -

- {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.'} -

-
-
+
+

+ + + + {language === 'de' ? 'Einwilligungen' : 'Consents'} +

+

+ {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.'} +

+
) } return ( - - - - +
+ {/* Header */} +
+

+ + + {language === 'de' ? 'Einwilligungen' : 'Consents'} - - +

+

{dataPoints.length} {language === 'de' ? 'Datenpunkte aus' : 'data points from'}{' '} {stats.categoryCount} {language === 'de' ? 'Kategorien' : 'categories'} - - +

+
- - {/* Statistik-Badges */} -
- {stats.riskCounts.HIGH > 0 && ( - - {stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'} - - )} - {stats.riskCounts.MEDIUM > 0 && ( - - {stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'} - - )} - {stats.riskCounts.LOW > 0 && ( - - {stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'} - - )} - {stats.specialCategoryCount > 0 && ( - - - {stats.specialCategoryCount} Art. 9 - - )} -
+ {/* Statistik-Badges */} +
+ {stats.riskCounts.HIGH > 0 && ( + + {stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'} + + )} + {stats.riskCounts.MEDIUM > 0 && ( + + {stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'} + + )} + {stats.riskCounts.LOW > 0 && ( + + {stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'} + + )} + {stats.specialCategoryCount > 0 && ( + + + + + {stats.specialCategoryCount} Art. 9 + + )} +
- +
- {/* Datenpunkte nach Kategorie */} - - - {sortedCategories.map(([category, points]) => { - const metadata = CATEGORY_METADATA[category as DataPointCategory] - if (!metadata) return null + {/* Datenpunkte nach Kategorie */} +
+ {sortedCategories.map(([category, points]) => { + const metadata = CATEGORY_METADATA[category as DataPointCategory] + if (!metadata) return null + const isExpanded = expandedCategories.includes(category) - return ( - - -
- - {metadata.code} - - - {language === 'de' ? metadata.name.de : metadata.name.en} - - - {points.length} - -
-
- -
    - {points.map(dp => ( -
  • - - {language === 'de' ? dp.name.de : dp.name.en} - -
    - {dp.isSpecialCategory && ( - - - - - - - {language === 'de' - ? 'Besondere Kategorie (Art. 9 DSGVO)' - : 'Special Category (Art. 9 GDPR)'} - - - - )} - - {RISK_LEVEL_STYLING[dp.riskLevel].label[language]} - -
    -
  • - ))} -
-
-
- ) - })} - - + return ( +
+ - - - {/* Schnell-Einfügen Buttons */} -
-

- {language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'} -

-
- - {PLACEHOLDERS.slice(0, 4).map(({ placeholder, label, description, icon: Icon }) => ( - - - - - -

- {language === 'de' ? description.de : description.en} -

-

- {placeholder} -

-
-
- ))} -
-
-
- - {PLACEHOLDERS.slice(4).map(({ placeholder, label, description, icon: Icon }) => ( - - - - - -

- {language === 'de' ? description.de : description.en} -

-

- {placeholder} -

-
-
- ))} -
-
+ + {language === 'de' ? dp.name.de : dp.name.en} + +
+ {dp.isSpecialCategory && ( + + + + )} + + {RISK_LEVEL_STYLING[dp.riskLevel].label[language]} + +
+ + ))} + + )} +
+ ) + })} +
+ +
+ + {/* Schnell-Einfügen Buttons */} +
+

+ {language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'} +

+
+ {PLACEHOLDERS.slice(0, 4).map(({ placeholder, label }) => ( + + ))}
- - +
+ {PLACEHOLDERS.slice(4).map(({ placeholder, label }) => ( + + ))} +
+
+
) } diff --git a/admin-v2/app/(sdk)/sdk/document-generator/components/DocumentValidation.tsx b/admin-v2/app/(sdk)/sdk/document-generator/components/DocumentValidation.tsx index cfbe4e2..9cc6b0c 100644 --- a/admin-v2/app/(sdk)/sdk/document-generator/components/DocumentValidation.tsx +++ b/admin-v2/app/(sdk)/sdk/document-generator/components/DocumentValidation.tsx @@ -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([]) + // 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 ( - - - - {language === 'de' ? 'Dokument valide' : 'Document valid'} - - - {language === 'de' - ? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.' - : 'All necessary sections for the selected data points are present.'} - - +
+
+ + + +
+

+ {language === 'de' ? 'Dokument valide' : 'Document valid'} +

+

+ {language === 'de' + ? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.' + : 'All necessary sections for the selected data points are present.'} +

+
+
+
) } return null @@ -109,91 +82,126 @@ export function DocumentValidation({
{/* Zusammenfassung */}
- + {language === 'de' ? 'Validierung:' : 'Validation:'} {errorCount > 0 && ( - - {errorCount} {language === 'de' ? 'Fehler' : 'Error'} - {errorCount > 1 && (language === 'de' ? '' : 's')} - + + {errorCount} {language === 'de' ? 'Fehler' : 'Error'}{errorCount > 1 && 's'} + )} {warningCount > 0 && ( - - {warningCount} {language === 'de' ? 'Warnung' : 'Warning'} - {warningCount > 1 && (language === 'de' ? 'en' : 's')} - + + {warningCount} {language === 'de' ? 'Warnung' : 'Warning'}{warningCount > 1 && (language === 'de' ? 'en' : 's')} + )} {infoCount > 0 && ( - - {infoCount} {language === 'de' ? 'Hinweis' : 'Info'} - {infoCount > 1 && (language === 'de' ? 'e' : 's')} - + + {infoCount} {language === 'de' ? 'Hinweis' : 'Info'}{infoCount > 1 && (language === 'de' ? 'e' : 's')} + )}
{/* 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 ( - - - - {warning.message} - - - {/* Vorschlag */} -
- - {warning.suggestion} +
+
+ {/* Icon */} + + {isError ? ( + + ) : ( + + )} + + +
+ {/* Message */} +

+ {warning.message} +

+ + {/* Suggestion */} +
+ + + + {warning.suggestion} +
+ + {/* Quick-Fix Button */} + {placeholder && onInsertPlaceholder && ( + + )} + + {/* Betroffene Datenpunkte */} + {warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && ( +
+ + + {isExpanded && ( +
    + {warning.affectedDataPoints.slice(0, 5).map(dp => ( +
  • + {language === 'de' ? dp.name.de : dp.name.en} +
  • + ))} + {warning.affectedDataPoints.length > 5 && ( +
  • + ... {language === 'de' ? 'und' : 'and'}{' '} + {warning.affectedDataPoints.length - 5}{' '} + {language === 'de' ? 'weitere' : 'more'} +
  • + )} +
+ )} +
+ )}
- - {/* Quick-Fix Button */} - {placeholder && onInsertPlaceholder && ( - - )} - - {/* Betroffene Datenpunkte */} - {warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && ( - - - - {warning.affectedDataPoints.length}{' '} - {language === 'de' ? 'betroffene Datenpunkte' : 'affected data points'} - - -
    - {warning.affectedDataPoints.slice(0, 5).map(dp => ( -
  • - {language === 'de' ? dp.name.de : dp.name.en} -
  • - ))} - {warning.affectedDataPoints.length > 5 && ( -
  • - ... {language === 'de' ? 'und' : 'and'}{' '} - {warning.affectedDataPoints.length - 5}{' '} - {language === 'de' ? 'weitere' : 'more'} -
  • - )} -
-
-
- )} - - +
+
) })}
diff --git a/admin-v2/components/QRCodeUpload.tsx b/admin-v2/components/QRCodeUpload.tsx new file mode 100644 index 0000000..19b0652 --- /dev/null +++ b/admin-v2/components/QRCodeUpload.tsx @@ -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(null) + const [uploadUrl, setUploadUrl] = useState('') + const [isLoading, setIsLoading] = useState(true) + const [uploadedFiles, setUploadedFiles] = useState([]) + 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 = { + '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 ( +
+
+
+
+
+ 📱 +
+
+

Mit Mobiltelefon hochladen

+

QR-Code scannen oder Link teilen

+
+
+ {onClose && ( + + )} +
+ +
+
+ {isLoading ? ( +
+
+
+ ) : qrCodeUrl ? ( + QR Code zum Hochladen + ) : ( +
+ QR-Code nicht verfuegbar +
+ )} +
+

+ Scannen Sie diesen Code mit Ihrem Handy,
um Dokumente direkt hochzuladen. +

+ {isPolling && ( +
+
+ Warte auf Uploads... +
+ )} +
+ + {uploadedFiles.length > 0 && ( +
+

+ {uploadedFiles.length} Datei{uploadedFiles.length !== 1 ? 'en' : ''} empfangen +

+
+ {uploadedFiles.map((file) => ( +
+ {file.type.startsWith('image/') ? '🖼️' : '📄'} +
+

{file.name}

+

{formatFileSize(file.size)}

+
+ +
+ ))} +
+
+ )} + +
+

Oder Link teilen:

+
+ + +
+
+
+
+ ) +}