diff --git a/docs/todo.md b/docs/todo.md index acc3e72f..0f1c879d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,24 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🆕 JPGs → PDF: Button überall bei PDF-Upload** + - Neue Komponente `JpgToPdfModal` (lokal im Browser via `jspdf`, + keine Backend-Round-Trip nötig). Mehrere Bilder hinzufügen per + Klick, Drag&Drop oder `Strg+V` (Clipboard-Image), Reihenfolge + per Drag&Drop sortierbar, pro Bild 90°/180°-Drehung + + Horizontal/Vertikal-Spiegelung. Quality 100%, 1 Bild = 1 Seite, + A4 mit automatischer Hoch-/Querformat-Wahl je Bild. + - `FileUpload`-Komponente (11 Stellen: Datenschutz-PDF, + Vollmacht, Bankkarten-Dokumente, Ausweise, Gewerbeanmeldung, + Handelsregister, Kündigungsschreiben + -Bestätigung + + deren Optionen) bekommt automatisch einen sekundären + "JPGs → PDF"-Button, wenn `accept` PDF einschließt. + - Direkt-Inputs ebenfalls erweitert: Vertragsdokumente + (ContractDetail), Vollmacht-Dokumente (CustomerDetail Tab), + Rechnungen (InvoicesSection). + - PdfTemplates **bewusst ausgenommen** – braucht echte + AcroForm-PDFs mit Formularfeldern, Bild-PDFs wären unbrauchbar. + - [x] **🆕 EmailProvider-Settings: Override-Feld „Bezeichnung im UI"** - `customerEmailLabel` existierte im Backend (Schema + Update-Logik + Public-Endpoint), war im UI aber nicht diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7d1a2a41..8fd90ef3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencrm-frontend", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencrm-frontend", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "@tanstack/react-query": "^5.59.0", "@tiptap/extension-link": "^3.19.0", @@ -15,6 +15,7 @@ "@tiptap/starter-kit": "^3.19.0", "axios": "^1.7.7", "dompurify": "^3.4.1", + "jspdf": "^4.2.1", "lucide-react": "^0.454.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -264,6 +265,14 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1632,11 +1641,22 @@ "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "optional": true + }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", @@ -1767,6 +1787,15 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.16", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz", @@ -1874,6 +1903,25 @@ } ] }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1936,11 +1984,31 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2169,6 +2237,16 @@ "node": ">= 6" } }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2178,6 +2256,11 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2370,6 +2453,24 @@ "node": ">= 0.4" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2465,6 +2566,22 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2667,12 +2784,23 @@ "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3075,6 +3203,15 @@ } ] }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -3189,6 +3326,12 @@ "node": ">=8.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "optional": true + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3219,6 +3362,15 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.55.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz", @@ -3317,6 +3469,15 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3351,6 +3512,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -3388,6 +3558,15 @@ "node": ">=14.0.0" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -3534,6 +3713,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index 952f6524..aa62772b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@tiptap/starter-kit": "^3.19.0", "axios": "^1.7.7", "dompurify": "^3.4.1", + "jspdf": "^4.2.1", "lucide-react": "^0.454.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/components/contracts/InvoicesSection.tsx b/frontend/src/components/contracts/InvoicesSection.tsx index 2cdd5a47..86b376c3 100644 --- a/frontend/src/components/contracts/InvoicesSection.tsx +++ b/frontend/src/components/contracts/InvoicesSection.tsx @@ -1,7 +1,8 @@ import { useState, useRef } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { formatDate } from '../../utils/dateFormat'; -import { Plus, Edit, Trash2, ChevronDown, ChevronUp, FileText, Download, AlertTriangle, Check, Eye } from 'lucide-react'; +import { Plus, Edit, Trash2, ChevronDown, ChevronUp, FileText, Download, AlertTriangle, Check, Eye, Images } from 'lucide-react'; +import JpgToPdfModal from '../ui/JpgToPdfModal'; import Modal from '../ui/Modal'; import Button from '../ui/Button'; import Input from '../ui/Input'; @@ -218,6 +219,7 @@ function InvoiceModal({ }); const [selectedFile, setSelectedFile] = useState(null); const [error, setError] = useState(null); + const [isJpgModalOpen, setIsJpgModalOpen] = useState(false); const addInvoiceFn = async (data: { invoiceDate: string; invoiceType: string; notes?: string }) => { if (ecdId) { @@ -386,15 +388,31 @@ function InvoiceModal({ onChange={handleFileSelect} className="hidden" /> - +
+ + +
)} + setIsJpgModalOpen(false)} + onComplete={(file) => setSelectedFile(file)} + fileNameHint="rechnung" + /> {formData.invoiceType === 'NOT_AVAILABLE' && (
diff --git a/frontend/src/components/ui/FileUpload.tsx b/frontend/src/components/ui/FileUpload.tsx index afc4c129..ddcfa103 100644 --- a/frontend/src/components/ui/FileUpload.tsx +++ b/frontend/src/components/ui/FileUpload.tsx @@ -1,6 +1,7 @@ import { useRef, useState } from 'react'; -import { Upload } from 'lucide-react'; +import { Upload, Images } from 'lucide-react'; import Button from './Button'; +import JpgToPdfModal from './JpgToPdfModal'; interface FileUploadProps { onUpload: (file: File) => Promise; @@ -8,6 +9,10 @@ interface FileUploadProps { accept?: string; label?: string; disabled?: boolean; + /** Standard: aktiv, sobald `accept` PDF einschlieĂźt. Explizit auf false setzen, um den Button auszublenden. */ + enableJpgToPdf?: boolean; + /** Default-Name (ohne .pdf) fĂĽr die aus JPGs erzeugte PDF. */ + jpgToPdfFileNameHint?: string; } export default function FileUpload({ @@ -16,10 +21,16 @@ export default function FileUpload({ accept = '.pdf,.jpg,.jpeg,.png', label = 'Dokument hochladen', disabled = false, + enableJpgToPdf, + jpgToPdfFileNameHint, }: FileUploadProps) { const inputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const [dragOver, setDragOver] = useState(false); + const [isJpgModalOpen, setIsJpgModalOpen] = useState(false); + + const acceptsPdf = /pdf/i.test(accept); + const showJpgButton = (enableJpgToPdf ?? acceptsPdf) && !disabled; const handleFileSelect = async (file: File) => { if (!file) return; @@ -64,40 +75,67 @@ export default function FileUpload({
{existingFile ? ( !disabled && ( - +
+ + {showJpgButton && ( + + )} +
) ) : ( -
!disabled && inputRef.current?.click()} - onDrop={!disabled ? handleDrop : undefined} - onDragOver={!disabled ? handleDragOver : undefined} - onDragLeave={!disabled ? handleDragLeave : undefined} - > - {isUploading ? ( -
-
- Wird hochgeladen... + <> +
!disabled && inputRef.current?.click()} + onDrop={!disabled ? handleDrop : undefined} + onDragOver={!disabled ? handleDragOver : undefined} + onDragLeave={!disabled ? handleDragLeave : undefined} + > + {isUploading ? ( +
+
+ Wird hochgeladen... +
+ ) : ( + <> + +

{label}

+

PDF, JPG oder PNG (max. 10MB)

+ + )} +
+ {showJpgButton && !isUploading && ( +
+
- ) : ( - <> - -

{label}

-

PDF, JPG oder PNG (max. 10MB)

- )} -
+ )} + + setIsJpgModalOpen(false)} + onComplete={(file) => { + handleFileSelect(file); + }} + fileNameHint={jpgToPdfFileNameHint} + />
); } diff --git a/frontend/src/components/ui/JpgToPdfModal.tsx b/frontend/src/components/ui/JpgToPdfModal.tsx new file mode 100644 index 00000000..f4e97811 --- /dev/null +++ b/frontend/src/components/ui/JpgToPdfModal.tsx @@ -0,0 +1,443 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { jsPDF } from 'jspdf'; +import { + Upload, + RotateCcw, + RotateCw, + FlipHorizontal, + FlipVertical, + Trash2, + FileText, + Repeat, +} from 'lucide-react'; +import Modal from './Modal'; +import Button from './Button'; + +interface ImageItem { + id: string; + dataUrl: string; + naturalWidth: number; + naturalHeight: number; + rotation: 0 | 90 | 180 | 270; + flipH: boolean; + flipV: boolean; + fileName: string; +} + +interface JpgToPdfModalProps { + isOpen: boolean; + onClose: () => void; + onComplete: (pdfFile: File) => void; + fileNameHint?: string; +} + +function makeId() { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); +} + +function loadImage(dataUrl: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Bild-Decode fehlgeschlagen')); + img.src = dataUrl; + }); +} + +function renderImageToCanvas(image: HTMLImageElement, item: ImageItem): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas-Kontext konnte nicht erstellt werden'); + + const w = image.naturalWidth; + const h = image.naturalHeight; + const rotated = item.rotation === 90 || item.rotation === 270; + + canvas.width = rotated ? h : w; + canvas.height = rotated ? w : h; + + ctx.save(); + ctx.translate(canvas.width / 2, canvas.height / 2); + ctx.rotate((item.rotation * Math.PI) / 180); + ctx.scale(item.flipH ? -1 : 1, item.flipV ? -1 : 1); + ctx.drawImage(image, -w / 2, -h / 2); + ctx.restore(); + + return canvas; +} + +export default function JpgToPdfModal({ + isOpen, + onClose, + onComplete, + fileNameHint, +}: JpgToPdfModalProps) { + const [images, setImages] = useState([]); + const [dragSrcIdx, setDragSrcIdx] = useState(null); + const [isOverModal, setIsOverModal] = useState(false); + const [isBuilding, setIsBuilding] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + useEffect(() => { + if (!isOpen) { + setImages([]); + setDragSrcIdx(null); + setIsOverModal(false); + setIsBuilding(false); + setError(null); + } + }, [isOpen]); + + const addFiles = useCallback(async (files: FileList | File[]) => { + const list = Array.from(files).filter((f) => f.type.startsWith('image/')); + if (list.length === 0) { + setError('Nur Bilddateien erlaubt (JPG/PNG).'); + return; + } + setError(null); + const added: ImageItem[] = []; + for (const file of list) { + try { + const dataUrl = await readFileAsDataUrl(file); + const img = await loadImage(dataUrl); + added.push({ + id: makeId(), + dataUrl, + naturalWidth: img.naturalWidth, + naturalHeight: img.naturalHeight, + rotation: 0, + flipH: false, + flipV: false, + fileName: file.name || 'clipboard.png', + }); + } catch { + setError(`Bild konnte nicht geladen werden: ${file.name || 'unbenannt'}`); + } + } + if (added.length > 0) { + setImages((prev) => [...prev, ...added]); + } + }, []); + + useEffect(() => { + if (!isOpen) return; + const handler = (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + const files: File[] = []; + for (let i = 0; i < items.length; i++) { + const it = items[i]; + if (it.kind === 'file' && it.type.startsWith('image/')) { + const f = it.getAsFile(); + if (f) files.push(f); + } + } + if (files.length > 0) { + e.preventDefault(); + addFiles(files); + } + }; + document.addEventListener('paste', handler); + return () => document.removeEventListener('paste', handler); + }, [isOpen, addFiles]); + + const rotate = (id: string, delta: 90 | -90 | 180) => { + setImages((prev) => + prev.map((it) => + it.id === id + ? { + ...it, + rotation: ((((it.rotation + delta) % 360) + 360) % 360) as ImageItem['rotation'], + } + : it + ) + ); + }; + + const flip = (id: string, axis: 'h' | 'v') => { + setImages((prev) => + prev.map((it) => + it.id === id + ? { ...it, ...(axis === 'h' ? { flipH: !it.flipH } : { flipV: !it.flipV }) } + : it + ) + ); + }; + + const remove = (id: string) => { + setImages((prev) => prev.filter((it) => it.id !== id)); + }; + + const handleDragStart = (idx: number) => (e: React.DragEvent) => { + setDragSrcIdx(idx); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(idx)); + }; + + const handleItemDragOver = (e: React.DragEvent) => { + if (dragSrcIdx === null) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDropOnItem = (idx: number) => (e: React.DragEvent) => { + if (dragSrcIdx === null) return; + e.preventDefault(); + e.stopPropagation(); + if (dragSrcIdx === idx) { + setDragSrcIdx(null); + return; + } + setImages((prev) => { + const next = [...prev]; + const [moved] = next.splice(dragSrcIdx, 1); + next.splice(idx, 0, moved); + return next; + }); + setDragSrcIdx(null); + }; + + const handleModalDragOver = (e: React.DragEvent) => { + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault(); + setIsOverModal(true); + } + }; + const handleModalDragLeave = (e: React.DragEvent) => { + if (e.target === e.currentTarget) setIsOverModal(false); + }; + const handleModalDrop = (e: React.DragEvent) => { + if (e.dataTransfer.files.length > 0) { + e.preventDefault(); + setIsOverModal(false); + addFiles(e.dataTransfer.files); + } + }; + + const buildPdf = async () => { + if (images.length === 0) return; + setIsBuilding(true); + setError(null); + try { + const a4w = 210; + const a4h = 297; + let pdf: jsPDF | null = null; + + for (let i = 0; i < images.length; i++) { + const item = images[i]; + const img = await loadImage(item.dataUrl); + const canvas = renderImageToCanvas(img, item); + const jpegDataUrl = canvas.toDataURL('image/jpeg', 1.0); + + const orientation: 'portrait' | 'landscape' = + canvas.width > canvas.height ? 'landscape' : 'portrait'; + const pageW = orientation === 'landscape' ? a4h : a4w; + const pageH = orientation === 'landscape' ? a4w : a4h; + + if (!pdf) { + pdf = new jsPDF({ orientation, unit: 'mm', format: 'a4' }); + } else { + pdf.addPage('a4', orientation); + } + + const margin = 5; + const maxW = pageW - 2 * margin; + const maxH = pageH - 2 * margin; + const ratio = Math.min(maxW / canvas.width, maxH / canvas.height); + const w = canvas.width * ratio; + const h = canvas.height * ratio; + const x = (pageW - w) / 2; + const y = (pageH - h) / 2; + + pdf.addImage(jpegDataUrl, 'JPEG', x, y, w, h, undefined, 'SLOW'); + } + + const blob = pdf!.output('blob'); + const base = (fileNameHint || 'bilder').replace(/[^\w.-]+/g, '_').slice(0, 80) || 'bilder'; + const file = new File([blob], `${base}.pdf`, { type: 'application/pdf' }); + onComplete(file); + onClose(); + } catch (e) { + console.error('PDF-Erstellung fehlgeschlagen:', e); + setError('PDF konnte nicht erstellt werden.'); + } finally { + setIsBuilding(false); + } + }; + + return ( + +
+
+

+ Bilder per Klick wählen, hier reinziehen, oder mit{' '} + Strg+ + V aus der Zwischenablage + einfügen. +

+ + { + if (e.target.files && e.target.files.length > 0) addFiles(e.target.files); + e.target.value = ''; + }} + /> +
+ + {error && ( +
+ {error} +
+ )} + + {images.length === 0 ? ( +
fileInputRef.current?.click()} + className="border-2 border-dashed rounded-lg p-12 text-center cursor-pointer border-gray-300 hover:border-gray-400 hover:bg-gray-50" + > + +

+ Bilder hier hineinziehen oder zum Auswählen klicken +

+

+ JPG / PNG · Reihenfolge per Drag & Drop, einzeln drehen/spiegeln +

+
+ ) : ( + <> +
+ {images.length} {images.length === 1 ? 'Bild' : 'Bilder'} · Reihenfolge per + Drag&Drop, einzeln drehen/spiegeln. Jedes Bild = eine Seite. +
+
+ {images.map((item, idx) => { + const transforms = [ + `rotate(${item.rotation}deg)`, + `scaleX(${item.flipH ? -1 : 1})`, + `scaleY(${item.flipV ? -1 : 1})`, + ].join(' '); + return ( +
+
+ #{idx + 1} + +
+
+ {item.fileName} +
+
+ + + + + +
+
+ ); + })} +
+ + )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/contracts/ContractDetail.tsx b/frontend/src/pages/contracts/ContractDetail.tsx index 13b41ba8..5ebcd4d4 100644 --- a/frontend/src/pages/contracts/ContractDetail.tsx +++ b/frontend/src/pages/contracts/ContractDetail.tsx @@ -14,7 +14,8 @@ import Badge from '../../components/ui/Badge'; import Input from '../../components/ui/Input'; import Modal from '../../components/ui/Modal'; import FileUpload from '../../components/ui/FileUpload'; -import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield, FileText } from 'lucide-react'; +import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield, FileText, Images } from 'lucide-react'; +import JpgToPdfModal from '../../components/ui/JpgToPdfModal'; import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations'; import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton'; import { formatDate } from '../../utils/dateFormat'; @@ -3484,6 +3485,7 @@ function ContractDocumentsSection({ const [uploadDeliveryDate, setUploadDeliveryDate] = useState( () => new Date().toISOString().split('T')[0], ); + const [isJpgModalOpen, setIsJpgModalOpen] = useState(false); const { data: docsData } = useQuery({ queryKey: ['contract-documents', contractId], @@ -3581,15 +3583,31 @@ function ContractDocumentsSection({

)} -
+
+ {uploadMutation.isPending && Hochladen...}
+ setIsJpgModalOpen(false)} + onComplete={(file) => { + uploadMutation.mutate({ + file, + documentType: uploadType, + notes: uploadNotes || undefined, + deliveryDate: isDelivery ? uploadDeliveryDate : undefined, + }); + }} + fileNameHint={uploadType} + /> {uploadMutation.isError && (

Fehler beim Hochladen

)} diff --git a/frontend/src/pages/customers/CustomerDetail.tsx b/frontend/src/pages/customers/CustomerDetail.tsx index 384c34f4..83d5289a 100644 --- a/frontend/src/pages/customers/CustomerDetail.tsx +++ b/frontend/src/pages/customers/CustomerDetail.tsx @@ -14,7 +14,8 @@ import Modal from '../../components/ui/Modal'; import Input from '../../components/ui/Input'; import Select from '../../components/ui/Select'; 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 } from 'lucide-react'; +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 BirthdayManagementModal from '../../components/BirthdayManagementModal'; import { formatDate } from '../../utils/dateFormat'; @@ -4560,6 +4561,7 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb const queryClient = useQueryClient(); const { user } = useAuth(); const [sendDropdownFor, setSendDropdownFor] = useState(null); + const [jpgModalFor, setJpgModalFor] = useState(null); const { data: authData, isLoading } = useQuery({ queryKey: ['authorizations', customerId], @@ -4730,21 +4732,42 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb ) : ( - + <> + + + )}
))}
+ setJpgModalFor(null)} + onComplete={(file) => { + if (jpgModalFor !== null) { + uploadMutation.mutate({ representativeId: jpgModalFor, file }); + } + }} + fileNameHint="vollmacht" + />
); }