JPGs → PDF: neuer Button überall bei PDF-Upload
- Neue Komponente JpgToPdfModal (jsPDF clientseitig, kein Backend-Roundtrip). - Bilder hinzufügen per Klick, Drag&Drop oder Strg+V (Clipboard). - Reihenfolge per Drag&Drop sortierbar; pro Bild 90°/180°-Drehung + horizontal/vertikal-Spiegelung. - Jedes Bild = eine A4-Seite, Orientation automatisch nach Bild, JPEG-Qualität 100%. - FileUpload-Komponente zeigt den Sekundär-Button automatisch, sobald accept PDF einschließt (Datenschutz, Vollmacht, Bankkarten, Ausweise, Gewerbeanmeldung, Handelsregister, Kündigungsschreiben/-bestätigung + jeweilige Optionen). - Direktinputs ebenfalls erweitert: Vertragsdokumente (ContractDetail), Vollmacht-Tab (CustomerDetail), Rechnungen (InvoicesSection). - PdfTemplates bewusst ausgenommen – braucht AcroForm-Felder.
This commit is contained in:
Generated
+190
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<File | null>(null);
|
||||
const [error, setError] = useState<string | null>(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"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{invoice?.documentPath || selectedFile ? 'Ersetzen' : 'PDF hochladen'}
|
||||
</Button>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{invoice?.documentPath || selectedFile ? 'Ersetzen' : 'PDF hochladen'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setIsJpgModalOpen(true)}
|
||||
title="Mehrere JPGs zu einer PDF kombinieren"
|
||||
>
|
||||
<Images className="w-4 h-4 mr-1" /> JPGs → PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<JpgToPdfModal
|
||||
isOpen={isJpgModalOpen}
|
||||
onClose={() => setIsJpgModalOpen(false)}
|
||||
onComplete={(file) => setSelectedFile(file)}
|
||||
fileNameHint="rechnung"
|
||||
/>
|
||||
|
||||
{formData.invoiceType === 'NOT_AVAILABLE' && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm">
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -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<HTMLInputElement>(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({
|
||||
<div className="space-y-2">
|
||||
{existingFile ? (
|
||||
!disabled && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? 'Wird hochgeladen...' : 'Ersetzen'}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? 'Wird hochgeladen...' : 'Ersetzen'}
|
||||
</Button>
|
||||
{showJpgButton && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsJpgModalOpen(true)}
|
||||
disabled={isUploading}
|
||||
title="Mehrere JPGs zu einer PDF kombinieren"
|
||||
>
|
||||
<Images className="w-4 h-4 mr-1" /> JPGs → PDF
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
||||
dragOver
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
onDrop={!disabled ? handleDrop : undefined}
|
||||
onDragOver={!disabled ? handleDragOver : undefined}
|
||||
onDragLeave={!disabled ? handleDragLeave : undefined}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="text-gray-500">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2" />
|
||||
Wird hochgeladen...
|
||||
<>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
||||
dragOver
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
onDrop={!disabled ? handleDrop : undefined}
|
||||
onDragOver={!disabled ? handleDragOver : undefined}
|
||||
onDragLeave={!disabled ? handleDragLeave : undefined}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="text-gray-500">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2" />
|
||||
Wird hochgeladen...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-6 h-6 text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-600">{label}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">PDF, JPG oder PNG (max. 10MB)</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showJpgButton && !isUploading && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsJpgModalOpen(true)}
|
||||
title="Mehrere JPGs zu einer PDF kombinieren"
|
||||
>
|
||||
<Images className="w-4 h-4 mr-1" /> JPGs → PDF
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-6 h-6 text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-600">{label}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">PDF, JPG oder PNG (max. 10MB)</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<input
|
||||
@@ -108,6 +146,15 @@ export default function FileUpload({
|
||||
className="hidden"
|
||||
disabled={disabled || isUploading}
|
||||
/>
|
||||
|
||||
<JpgToPdfModal
|
||||
isOpen={isJpgModalOpen}
|
||||
onClose={() => setIsJpgModalOpen(false)}
|
||||
onComplete={(file) => {
|
||||
handleFileSelect(file);
|
||||
}}
|
||||
fileNameHint={jpgToPdfFileNameHint}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<HTMLImageElement> {
|
||||
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<ImageItem[]>([]);
|
||||
const [dragSrcIdx, setDragSrcIdx] = useState<number | null>(null);
|
||||
const [isOverModal, setIsOverModal] = useState(false);
|
||||
const [isBuilding, setIsBuilding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="JPGs zu PDF" size="xl">
|
||||
<div
|
||||
onDragOver={handleModalDragOver}
|
||||
onDragLeave={handleModalDragLeave}
|
||||
onDrop={handleModalDrop}
|
||||
className={`space-y-4 ${isOverModal ? 'ring-2 ring-blue-400 rounded-lg' : ''}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-gray-600">
|
||||
Bilder per Klick wählen, hier reinziehen, oder mit{' '}
|
||||
<kbd className="px-1.5 py-0.5 border rounded text-xs">Strg</kbd>+
|
||||
<kbd className="px-1.5 py-0.5 border rounded text-xs">V</kbd> aus der Zwischenablage
|
||||
einfügen.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isBuilding}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-1" /> Bilder hinzufügen
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files.length > 0) addFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length === 0 ? (
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Upload className="w-8 h-8 text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-600">
|
||||
Bilder hier hineinziehen oder zum Auswählen klicken
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
JPG / PNG · Reihenfolge per Drag & Drop, einzeln drehen/spiegeln
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs text-gray-500">
|
||||
{images.length} {images.length === 1 ? 'Bild' : 'Bilder'} · Reihenfolge per
|
||||
Drag&Drop, einzeln drehen/spiegeln. Jedes Bild = eine Seite.
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 max-h-[60vh] overflow-y-auto p-1">
|
||||
{images.map((item, idx) => {
|
||||
const transforms = [
|
||||
`rotate(${item.rotation}deg)`,
|
||||
`scaleX(${item.flipH ? -1 : 1})`,
|
||||
`scaleY(${item.flipV ? -1 : 1})`,
|
||||
].join(' ');
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={handleDragStart(idx)}
|
||||
onDragOver={handleItemDragOver}
|
||||
onDrop={handleDropOnItem(idx)}
|
||||
className={`border rounded-lg p-2 bg-white shadow-sm cursor-move ${
|
||||
dragSrcIdx === idx ? 'opacity-40' : ''
|
||||
}`}
|
||||
title="Zum Sortieren ziehen"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5 text-xs">
|
||||
<span className="font-semibold text-gray-700">#{idx + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(item.id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
title="Entfernen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="aspect-square bg-gray-100 rounded flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={item.dataUrl}
|
||||
alt={item.fileName}
|
||||
style={{ transform: transforms }}
|
||||
className="max-w-full max-h-full object-contain transition-transform"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rotate(item.id, -90)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
title="90° gegen Uhrzeigersinn"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rotate(item.id, 90)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
title="90° im Uhrzeigersinn"
|
||||
>
|
||||
<RotateCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rotate(item.id, 180)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
title="180° drehen"
|
||||
>
|
||||
<Repeat className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => flip(item.id, 'h')}
|
||||
className={`p-1 rounded ${
|
||||
item.flipH ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
|
||||
}`}
|
||||
title="Horizontal spiegeln"
|
||||
>
|
||||
<FlipHorizontal className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => flip(item.id, 'v')}
|
||||
className={`p-1 rounded ${
|
||||
item.flipV ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
|
||||
}`}
|
||||
title="Vertikal spiegeln"
|
||||
>
|
||||
<FlipVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t">
|
||||
<Button variant="ghost" onClick={onClose} disabled={isBuilding}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={buildPdf} disabled={images.length === 0 || isBuilding}>
|
||||
<FileText className="w-4 h-4 mr-1" />
|
||||
{isBuilding ? 'Erstelle PDF...' : 'PDF erstellen & hochladen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<string>(
|
||||
() => 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({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<label className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer text-sm">
|
||||
<Plus className="w-4 h-4" />
|
||||
Datei wählen (PDF, JPG, PNG)
|
||||
<input type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={handleFileSelect} />
|
||||
</label>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsJpgModalOpen(true)} title="Mehrere JPGs zu einer PDF kombinieren">
|
||||
<Images className="w-4 h-4 mr-1" /> JPGs → PDF
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowUpload(false)}>Abbrechen</Button>
|
||||
{uploadMutation.isPending && <span className="text-sm text-gray-500">Hochladen...</span>}
|
||||
</div>
|
||||
<JpgToPdfModal
|
||||
isOpen={isJpgModalOpen}
|
||||
onClose={() => setIsJpgModalOpen(false)}
|
||||
onComplete={(file) => {
|
||||
uploadMutation.mutate({
|
||||
file,
|
||||
documentType: uploadType,
|
||||
notes: uploadNotes || undefined,
|
||||
deliveryDate: isDelivery ? uploadDeliveryDate : undefined,
|
||||
});
|
||||
}}
|
||||
fileNameHint={uploadType}
|
||||
/>
|
||||
{uploadMutation.isError && (
|
||||
<p className="text-xs text-red-600 mt-2">Fehler beim Hochladen</p>
|
||||
)}
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [jpgModalFor, setJpgModalFor] = useState<number | null>(null);
|
||||
|
||||
const { data: authData, isLoading } = useQuery({
|
||||
queryKey: ['authorizations', customerId],
|
||||
@@ -4730,21 +4732,42 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label className="text-xs text-blue-600 hover:underline cursor-pointer flex items-center gap-1">
|
||||
<Plus className="w-3 h-3" />
|
||||
Vollmacht-PDF hochladen
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileUpload(auth.representativeId, e)}
|
||||
/>
|
||||
</label>
|
||||
<>
|
||||
<label className="text-xs text-blue-600 hover:underline cursor-pointer flex items-center gap-1">
|
||||
<Plus className="w-3 h-3" />
|
||||
Vollmacht-PDF hochladen
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileUpload(auth.representativeId, e)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setJpgModalFor(auth.representativeId)}
|
||||
className="text-xs text-blue-600 hover:underline cursor-pointer flex items-center gap-1"
|
||||
title="Mehrere JPGs zu einer PDF kombinieren"
|
||||
>
|
||||
<Images className="w-3 h-3" />
|
||||
JPGs → PDF
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<JpgToPdfModal
|
||||
isOpen={jpgModalFor !== null}
|
||||
onClose={() => setJpgModalFor(null)}
|
||||
onComplete={(file) => {
|
||||
if (jpgModalFor !== null) {
|
||||
uploadMutation.mutate({ representativeId: jpgModalFor, file });
|
||||
}
|
||||
}}
|
||||
fileNameHint="vollmacht"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user