From a509bb7826e50554fc38d5702612cc36ae009cb4 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 30 Jul 2026 11:50:01 +0200 Subject: [PATCH] PdfDragButton: PDF per Drag-and-Drop aus Bankkarte/Ausweis ziehen Neue wiederverwendbare Komponente PdfDragButton: ziehbares Element, mit dem der hinterlegte Scan direkt aus dem Browser in ein Mail-Fenster (Anhang) oder den Datei-Explorer gezogen werden kann (Chromium- DownloadURL, Format ::). Bewusste Plattform-Grenze: PDF per Strg+V als Datei einfuegen geht im Browser nicht (Web-Clipboard darf keine OS-Datei-Zwischenablage befuellen) -> Drag-and-Drop. DownloadURL nur Chrome/Edge, nicht Firefox (dort Klick-Fallback: Datei im Tab oeffnen). Eingebaut an: ContractForm (neben IBAN/Ausweisnummer-Copy), Vertrags- ansicht (Bankkarte/Ausweis-Card), Kundenakte-Tabs Bankkarten+Ausweise. Nur bei vorhandenem documentPath. Nutzt bestehende fileUrl()-Download- URL (Token als Query, Per-File-Ownership-Check unveraendert) - kein neuer Endpoint, keine neue Angriffsflaeche. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/todo.md | 18 ++++ frontend/src/components/ui/PdfDragButton.tsx | 96 +++++++++++++++++++ .../src/pages/contracts/ContractDetail.tsx | 17 ++++ frontend/src/pages/contracts/ContractForm.tsx | 13 +++ .../src/pages/customers/CustomerDetail.tsx | 9 ++ 5 files changed, 153 insertions(+) create mode 100644 frontend/src/components/ui/PdfDragButton.tsx diff --git a/docs/todo.md b/docs/todo.md index cce36db6..e960f391 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,24 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🖱️ PDF-Drag-and-Drop aus Bankkarte/Ausweis heraus** (2026-07-30) + - Neues wiederverwendbares `PdfDragButton`: ziehbares „📄 PDF ziehen"- + Element, mit dem der hinterlegte Scan direkt aus dem Browser in ein + Mail-Fenster (Anhang) oder den Datei-Explorer gezogen werden kann + (Chromium-`DownloadURL`, Format `::`). + - **Technik-Grenze bewusst:** „PDF per Strg+V als DATEI einfügen" geht im + Browser NICHT (Web-Clipboard darf keine OS-Datei-Zwischenablage/CF_HDROP + befüllen) → Drag-and-Drop statt Clipboard. `DownloadURL` nur Chrome/Edge, + nicht Firefox (dort Klick-Fallback: Datei im Tab öffnen). + - Eingebaut an: Vertrag-Neu/Bearbeiten (`ContractForm`, neben IBAN-/ + Ausweisnummer-Copy-Button), Vertragsansicht (`ContractDetail`, Bankkarte- + + Ausweis-Card), Kundenakte-Tabs Bankkarten + Ausweise (`CustomerDetail`, + neben Anzeigen/Download). Erscheint nur bei vorhandenem `documentPath`. + - Nutzt bestehende `fileUrl()`-Download-URL (Token als Query, Per-File- + Ownership-Check unverändert) – kein neuer Endpoint, keine neue Angriffs- + fläche. Ausweitung auf weitere PDF-Stellen (Rechnungen, Vertragsdoks, + Datenschutz) folgt bei Bedarf über dieselbe Komponente. + - [x] **📋 Vertragsformular: Copy-Buttons für Bankkarte (IBAN) + Ausweis** (2026-07-30) - In „Kundendaten verknüpfen" je ein Kopieren-Button neben dem Label, kopiert nur den reinen Wert der aktuell gewählten Option: bei Bankkarte diff --git a/frontend/src/components/ui/PdfDragButton.tsx b/frontend/src/components/ui/PdfDragButton.tsx new file mode 100644 index 00000000..5b5153a0 --- /dev/null +++ b/frontend/src/components/ui/PdfDragButton.tsx @@ -0,0 +1,96 @@ +import { GripVertical, FileDown } from 'lucide-react'; +import { fileUrl } from '../../utils/fileUrl'; + +/** + * Ziehbares Element, mit dem eine im Backend hinterlegte Datei (i.d.R. ein + * PDF-Scan) direkt aus dem Browser heraus in eine fremde Anwendung – z.B. + * das Anhang-Feld eines Mail-Fensters oder den Datei-Explorer – gezogen + * werden kann. + * + * Technik: Beim `dragstart` setzen wir den Chromium-spezifischen + * `DownloadURL`-DataTransfer-Eintrag (`::`). + * Chrome/Edge laden die Datei beim Ablegen von dieser URL nach und legen + * sie am Zielort als echte Datei ab. + * + * WICHTIG – Grenzen der Browser-Plattform: + * - „PDF per Strg+V als DATEI einfügen" ist im Browser NICHT möglich + * (die Web-Zwischenablage darf die OS-Datei-Zwischenablage / CF_HDROP + * nicht befüllen). Deshalb Drag-and-Drop statt Clipboard. + * - `DownloadURL` funktioniert nur in Chromium-Browsern (Chrome/Edge), + * nicht in Firefox. Dort passiert beim Ziehen nichts – der Klick- + * Fallback (Datei im neuen Tab öffnen) bleibt aber nutzbar. + */ +interface PdfDragButtonProps { + /** Server-Pfad der Datei (`documentPath`). Ohne Pfad wird nichts gerendert. */ + path: string | null | undefined; + /** Dateiname beim Ablegen. Ohne Endung wird sie aus dem Pfad ergänzt. */ + filename?: string; + label?: string; + title?: string; + className?: string; + size?: 'sm' | 'md'; +} + +const MIME_BY_EXT: Record = { + pdf: 'application/pdf', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', +}; + +function extOf(path: string): string { + const clean = path.split('?')[0].split('#')[0]; + const dot = clean.lastIndexOf('.'); + return dot === -1 ? '' : clean.slice(dot + 1).toLowerCase(); +} + +export default function PdfDragButton({ + path, + filename, + label = 'PDF ziehen', + title, + className = '', + size = 'sm', +}: PdfDragButtonProps) { + if (!path) return null; + + const ext = extOf(path); + const mime = MIME_BY_EXT[ext] || 'application/octet-stream'; + const base = (filename && filename.trim()) || 'dokument'; + // Ungültige Dateinamen-Zeichen entschärfen, Endung sicherstellen. + const safeBase = base.replace(/[\\/:*?"<>|]+/g, '_'); + const name = safeBase.includes('.') ? safeBase : `${safeBase}${ext ? `.${ext}` : ''}`; + + // Absolute URL, damit der Drop in fremde Apps die Datei laden kann. + // fileUrl() hängt das Auth-Token als Query-Parameter an (Download). + const absolute = `${window.location.origin}${fileUrl(path)}`; + + const handleDragStart = (e: React.DragEvent) => { + try { + e.dataTransfer.setData('DownloadURL', `${mime}:${name}:${absolute}`); + } catch { + /* manche Browser werfen bei DownloadURL – dann nur die URI-Fallbacks */ + } + e.dataTransfer.setData('text/uri-list', absolute); + e.dataTransfer.setData('text/plain', absolute); + e.dataTransfer.effectAllowed = 'copy'; + }; + + const iconSize = size === 'sm' ? 'w-3.5 h-3.5' : 'w-4 h-4'; + + return ( + window.open(fileUrl(path, { inline: true }), '_blank', 'noopener')} + title={title || 'In Outlook/Explorer ziehen (Chrome/Edge). Klick: im Tab öffnen.'} + className={`inline-flex items-center gap-1 cursor-grab active:cursor-grabbing select-none rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-xs text-gray-600 hover:text-blue-600 hover:border-blue-300 hover:bg-blue-50 transition-colors ${className}`} + > + + + {label} + + ); +} diff --git a/frontend/src/pages/contracts/ContractDetail.tsx b/frontend/src/pages/contracts/ContractDetail.tsx index e212d3cd..2784d3e2 100644 --- a/frontend/src/pages/contracts/ContractDetail.tsx +++ b/frontend/src/pages/contracts/ContractDetail.tsx @@ -19,6 +19,7 @@ import toast from 'react-hot-toast'; import JpgToPdfModal from '../../components/ui/JpgToPdfModal'; import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations'; import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton'; +import PdfDragButton from '../../components/ui/PdfDragButton'; import AutosaveDateInput from '../../components/ui/AutosaveDateInput'; import { formatDate } from '../../utils/dateFormat'; import { useProviderSettings } from '../../hooks/useProviderSettings'; @@ -2651,6 +2652,14 @@ export default function ContractDetail() { {c.bankCard.iban}

+ {c.bankCard.documentPath && ( +
+ +
+ )} {c.bankCard.bankName &&

{c.bankCard.bankName}

} {c.bankCard.description && (

@@ -2666,6 +2675,14 @@ export default function ContractDetail() {

{c.identityDocument.type}

+ {c.identityDocument.documentPath && ( +
+ +
+ )} )} diff --git a/frontend/src/pages/contracts/ContractForm.tsx b/frontend/src/pages/contracts/ContractForm.tsx index 1c195061..8169c1e5 100644 --- a/frontend/src/pages/contracts/ContractForm.tsx +++ b/frontend/src/pages/contracts/ContractForm.tsx @@ -10,6 +10,7 @@ import Button from '../../components/ui/Button'; import Input from '../../components/ui/Input'; import Select from '../../components/ui/Select'; import CopyButton from '../../components/ui/CopyButton'; +import PdfDragButton from '../../components/ui/PdfDragButton'; import CustomerInfoModal from '../../components/contracts/CustomerInfoModal'; import { buildContractLabelParts } from '../../utils/contractLabel'; import type { ContractType } from '../../types'; @@ -978,6 +979,12 @@ export default function ContractForm() { {selectedBankCard && ( )} + {selectedBankCard?.documentPath && ( + + )} } {...register('bankCardId')} @@ -1004,6 +1011,12 @@ export default function ContractForm() { {selectedDocument && ( )} + {selectedDocument?.documentPath && ( + + )} } {...register('identityDocumentId')} diff --git a/frontend/src/pages/customers/CustomerDetail.tsx b/frontend/src/pages/customers/CustomerDetail.tsx index 9e465ad0..4fec7e9b 100644 --- a/frontend/src/pages/customers/CustomerDetail.tsx +++ b/frontend/src/pages/customers/CustomerDetail.tsx @@ -18,6 +18,7 @@ import FileUpload from '../../components/ui/FileUpload'; import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft, Cake, RefreshCw, ExternalLink, Images } from 'lucide-react'; import JpgToPdfModal from '../../components/ui/JpgToPdfModal'; import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton'; +import PdfDragButton from '../../components/ui/PdfDragButton'; import BirthdayManagementModal from '../../components/BirthdayManagementModal'; import { formatDate } from '../../utils/dateFormat'; import { getContractTypeInfo } from '../../utils/contractInfo'; @@ -1002,6 +1003,10 @@ function BankCardsTab({ Download + {canEdit && ( <> Download + {canEdit && ( <>