From cffe934d5e49e7de16eb168deb5d40fc177e82c0 Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 29 May 2026 15:28:52 +0200 Subject: [PATCH] feat: Unterschriftsfelder im Logbuch per Touch, Stift oder Maus. Co-authored-by: Cursor --- client/src/App.css | 92 +++++++++ client/src/components/LogEntryEditor.tsx | 46 ++--- client/src/components/SignaturePad.tsx | 236 +++++++++++++++++++++++ client/src/i18n/locales/de.json | 6 +- client/src/i18n/locales/en.json | 6 +- client/src/services/csvExport.ts | 5 +- client/src/services/pdfExport.ts | 21 +- client/src/utils/signatures.ts | 9 + 8 files changed, 384 insertions(+), 37 deletions(-) create mode 100644 client/src/components/SignaturePad.tsx create mode 100644 client/src/utils/signatures.ts diff --git a/client/src/App.css b/client/src/App.css index 8219cae..ba929a4 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1980,6 +1980,98 @@ body:has(.theme-cupertino) { margin-bottom: 12px; } +.signature-grid { + align-items: start; +} + +.signature-pad-group { + width: 100%; +} + +.signature-pad-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.signature-pad-header label { + display: block; + font-size: 13.5px; + color: #94a3b8; + font-weight: 500; +} + +.signature-pad-clear { + display: inline-flex; + align-items: center; + gap: 6px; + background: transparent; + border: none; + color: #94a3b8; + font-size: 12px; + cursor: pointer; + padding: 0; +} + +.signature-pad-clear:hover { + color: #e2e8f0; + text-decoration: underline; +} + +.signature-pad { + position: relative; + width: 100%; + height: 132px; + border-radius: 10px; + border: 1px dashed rgba(148, 163, 184, 0.45); + background: rgba(255, 255, 255, 0.96); + overflow: hidden; + touch-action: none; +} + +.signature-pad.disabled { + opacity: 0.65; +} + +.signature-pad-canvas, +.signature-pad-image { + display: block; + width: 100%; + height: 100%; +} + +.signature-pad-canvas { + cursor: crosshair; +} + +.signature-pad-hint { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: #94a3b8; + font-size: 13px; + pointer-events: none; + user-select: none; +} + +.signature-legacy-text { + min-height: 132px; + display: flex; + align-items: center; + padding: 12px 16px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: #e2e8f0; + font-size: 18px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + /* PWA install prompt */ .pwa-install-banner { position: fixed; diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 5e6447a..75f4f63 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -8,7 +8,9 @@ import { syncLogbook } from '../services/sync.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react' import PhotoCapture from './PhotoCapture.tsx' +import SignaturePad from './SignaturePad.tsx' import { useDialog } from './ModalDialog.tsx' +import { isSignatureImage } from '../utils/signatures.js' import { getDecryptedTrack, saveUploadedTrack, @@ -566,8 +568,8 @@ export default function LogEntryEditor({ evening: parseFloat(fuelEvening) || 0, consumption: parseFloat(fuelConsumption) || 0 }, - signSkipper: signSkipper.trim(), - signCrew: signCrew.trim(), + signSkipper: isSignatureImage(signSkipper) ? signSkipper : signSkipper.trim(), + signCrew: isSignatureImage(signCrew) ? signCrew : signCrew.trim(), events } @@ -1212,30 +1214,24 @@ export default function LogEntryEditor({

{t('logs.signatures')}

-
-
- - setSignSkipper(e.target.value)} - disabled={saving || readOnly} - /> -
+
+ -
- - setSignCrew(e.target.value)} - disabled={saving || readOnly} - /> -
+
diff --git a/client/src/components/SignaturePad.tsx b/client/src/components/SignaturePad.tsx new file mode 100644 index 0000000..331904a --- /dev/null +++ b/client/src/components/SignaturePad.tsx @@ -0,0 +1,236 @@ +import { useEffect, useRef, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Eraser } from 'lucide-react' +import { isSignatureImage } from '../utils/signatures.js' + +interface SignaturePadProps { + id: string + label: string + value: string + onChange: (value: string) => void + disabled?: boolean + readOnly?: boolean +} + +const STROKE_COLOR = '#0f172a' +const STROKE_WIDTH = 2.2 + +export default function SignaturePad({ + id, + label, + value, + onChange, + disabled = false, + readOnly = false +}: SignaturePadProps) { + const { t } = useTranslation() + const containerRef = useRef(null) + const canvasRef = useRef(null) + const isDrawing = useRef(false) + const lastPoint = useRef<{ x: number; y: number } | null>(null) + const skipExternalRedraw = useRef(false) + const hasInk = useRef(false) + const [showHint, setShowHint] = useState(() => !value) + + const getContext = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return null + return canvas.getContext('2d') + }, []) + + const clearCanvas = useCallback(() => { + const canvas = canvasRef.current + const ctx = getContext() + if (!canvas || !ctx) return + ctx.save() + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.restore() + hasInk.current = false + }, [getContext]) + + const drawImageValue = useCallback((dataUrl: string) => { + const canvas = canvasRef.current + const ctx = getContext() + if (!canvas || !ctx) return + + const img = new Image() + img.onload = () => { + clearCanvas() + const width = canvas.clientWidth + const height = canvas.clientHeight + ctx.drawImage(img, 0, 0, width, height) + hasInk.current = true + } + img.src = dataUrl + }, [clearCanvas, getContext]) + + const setupCanvas = useCallback(() => { + const canvas = canvasRef.current + const container = containerRef.current + if (!canvas || !container) return + + const rect = container.getBoundingClientRect() + const width = Math.max(rect.width, 1) + const height = Math.max(rect.height, 1) + const dpr = window.devicePixelRatio || 1 + + canvas.width = Math.floor(width * dpr) + canvas.height = Math.floor(height * dpr) + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + + const ctx = canvas.getContext('2d') + if (!ctx) return + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.lineWidth = STROKE_WIDTH + ctx.strokeStyle = STROKE_COLOR + + if (value && isSignatureImage(value)) { + drawImageValue(value) + } else { + clearCanvas() + } + }, [clearCanvas, drawImageValue, value]) + + useEffect(() => { + setupCanvas() + window.addEventListener('resize', setupCanvas) + return () => window.removeEventListener('resize', setupCanvas) + }, [setupCanvas]) + + useEffect(() => { + if (skipExternalRedraw.current) { + skipExternalRedraw.current = false + return + } + if (value && isSignatureImage(value)) { + drawImageValue(value) + setShowHint(false) + } else if (!value) { + clearCanvas() + setShowHint(true) + } + }, [value, clearCanvas, drawImageValue]) + + const getPoint = (event: React.PointerEvent) => { + const canvas = canvasRef.current + if (!canvas) return null + const rect = canvas.getBoundingClientRect() + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top + } + } + + const commitCanvas = () => { + const canvas = canvasRef.current + if (!canvas) return + if (!hasInk.current) { + skipExternalRedraw.current = true + onChange('') + return + } + skipExternalRedraw.current = true + onChange(canvas.toDataURL('image/png')) + } + + const handlePointerDown = (event: React.PointerEvent) => { + if (readOnly || disabled) return + event.preventDefault() + const point = getPoint(event) + if (!point) return + + isDrawing.current = true + lastPoint.current = point + setShowHint(false) + event.currentTarget.setPointerCapture(event.pointerId) + } + + const handlePointerMove = (event: React.PointerEvent) => { + if (!isDrawing.current || readOnly || disabled) return + event.preventDefault() + + const point = getPoint(event) + const ctx = getContext() + const prev = lastPoint.current + if (!point || !ctx || !prev) return + + ctx.beginPath() + ctx.moveTo(prev.x, prev.y) + ctx.lineTo(point.x, point.y) + ctx.stroke() + + lastPoint.current = point + hasInk.current = true + } + + const finishStroke = (event: React.PointerEvent) => { + if (!isDrawing.current) return + isDrawing.current = false + lastPoint.current = null + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + commitCanvas() + } + + const handleClear = () => { + if (readOnly || disabled) return + clearCanvas() + skipExternalRedraw.current = true + setShowHint(true) + onChange('') + } + + const interactive = !readOnly && !disabled + + if (readOnly && value && !isSignatureImage(value)) { + return ( +
+ +
{value}
+
+ ) + } + + return ( +
+
+ + {interactive && ( + + )} +
+ +
+ {readOnly && value && isSignatureImage(value) ? ( + {label} + ) : ( + + )} + {interactive && showHint && ( + {t('logs.sign_hint')} + )} +
+
+ ) +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 3d23597..4d735af 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -113,8 +113,10 @@ "evening": "Stand abends", "consumption": "Tagesverbrauch", "signatures": "Unterschriften / Freigabe", - "sign_skipper": "Skipper (Blockschrift)", - "sign_crew": "Crew-Mitglied (Blockschrift)", + "sign_skipper": "Skipper-Unterschrift", + "sign_crew": "Crew-Unterschrift", + "sign_hint": "Mit Finger, Stift oder Maus unterschreiben", + "sign_clear": "Löschen", "no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!", "back_to_list": "Zurück zur Journal-Liste", "save": "Logbuchseite speichern", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 47c47e9..f0319bb 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -113,8 +113,10 @@ "evening": "Evening Level", "consumption": "Consumption", "signatures": "Signatures / Sign-Off", - "sign_skipper": "Skipper Signature", - "sign_crew": "Crew Signature", + "sign_skipper": "Skipper signature", + "sign_crew": "Crew signature", + "sign_hint": "Sign with finger, stylus, or mouse", + "sign_clear": "Clear", "no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!", "back_to_list": "Back to Journal List", "save": "Save Logbook Page", diff --git a/client/src/services/csvExport.ts b/client/src/services/csvExport.ts index 657b7d5..4fd99ae 100644 --- a/client/src/services/csvExport.ts +++ b/client/src/services/csvExport.ts @@ -2,6 +2,7 @@ import { db } from './db.js' import { getActiveMasterKey } from './auth.js' import { getLogbookKey } from './logbookKeys.js' import { decryptJson } from './crypto.js' +import { formatSignatureForExport } from '../utils/signatures.js' function escapeCsvValue(val: string | number | undefined | null): string { if (val === null || val === undefined) return ''; @@ -93,8 +94,8 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya const travelDay = entry.dayOfTravel || ''; const dep = entry.departure || ''; const dest = entry.destination || ''; - const signS = entry.signSkipper || ''; - const signC = entry.signCrew || ''; + const signS = formatSignatureForExport(entry.signSkipper); + const signC = formatSignatureForExport(entry.signCrew); const fwM = entry.freshwater?.morning ?? ''; const fwR = entry.freshwater?.refilled ?? ''; const fwE = entry.freshwater?.evening ?? ''; diff --git a/client/src/services/pdfExport.ts b/client/src/services/pdfExport.ts index 5318043..4ea2e89 100644 --- a/client/src/services/pdfExport.ts +++ b/client/src/services/pdfExport.ts @@ -3,6 +3,7 @@ import { db } from './db.js' import { getActiveMasterKey } from './auth.js' import { getLogbookKey } from './logbookKeys.js' import { decryptJson } from './crypto.js' +import { isSignatureImage } from '../utils/signatures.js' export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise { let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = ''; @@ -219,14 +220,22 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string, doc.line(sigX, sigY + rowHeight * 1.5, sigX + 157, sigY + rowHeight * 1.5); doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3); - doc.text('Skipper Unterschrift (in Blockschrift):', sigX + 2, sigY + 4.2); - doc.setFont('Helvetica', 'normal'); - doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2); + doc.text('Skipper Unterschrift:', sigX + 2, sigY + 4.2); + if (isSignatureImage(entry.signSkipper)) { + doc.addImage(entry.signSkipper, 'PNG', sigX + 2, sigY + 6, 72, 14) + } else { + doc.setFont('Helvetica', 'normal'); + doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2); + } doc.setFont('Helvetica', 'bold'); - doc.text('Crew Unterschrift (in Blockschrift):', sigX + 80.5, sigY + 4.2); - doc.setFont('Helvetica', 'normal'); - doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2); + doc.text('Crew Unterschrift:', sigX + 80.5, sigY + 4.2); + if (isSignatureImage(entry.signCrew)) { + doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14) + } else { + doc.setFont('Helvetica', 'normal'); + doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2); + } return doc; } diff --git a/client/src/utils/signatures.ts b/client/src/utils/signatures.ts new file mode 100644 index 0000000..0bb3454 --- /dev/null +++ b/client/src/utils/signatures.ts @@ -0,0 +1,9 @@ +export function isSignatureImage(value: string | undefined | null): boolean { + return typeof value === 'string' && value.startsWith('data:image/') +} + +export function formatSignatureForExport(value: string | undefined | null): string { + if (!value) return '' + if (isSignatureImage(value)) return '[Unterschrift]' + return value +}