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 && (
<>