feat: Unterschriftsfelder im Logbuch per Touch, Stift oder Maus.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1980,6 +1980,98 @@ body:has(.theme-cupertino) {
|
|||||||
margin-bottom: 12px;
|
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 prompt */
|
||||||
.pwa-install-banner {
|
.pwa-install-banner {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { syncLogbook } from '../services/sync.js'
|
|||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
|
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
|
||||||
import PhotoCapture from './PhotoCapture.tsx'
|
import PhotoCapture from './PhotoCapture.tsx'
|
||||||
|
import SignaturePad from './SignaturePad.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
|
import { isSignatureImage } from '../utils/signatures.js'
|
||||||
import {
|
import {
|
||||||
getDecryptedTrack,
|
getDecryptedTrack,
|
||||||
saveUploadedTrack,
|
saveUploadedTrack,
|
||||||
@@ -566,8 +568,8 @@ export default function LogEntryEditor({
|
|||||||
evening: parseFloat(fuelEvening) || 0,
|
evening: parseFloat(fuelEvening) || 0,
|
||||||
consumption: parseFloat(fuelConsumption) || 0
|
consumption: parseFloat(fuelConsumption) || 0
|
||||||
},
|
},
|
||||||
signSkipper: signSkipper.trim(),
|
signSkipper: isSignatureImage(signSkipper) ? signSkipper : signSkipper.trim(),
|
||||||
signCrew: signCrew.trim(),
|
signCrew: isSignatureImage(signCrew) ? signCrew : signCrew.trim(),
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1212,32 +1214,26 @@ export default function LogEntryEditor({
|
|||||||
<Check size={20} className="form-icon" />
|
<Check size={20} className="form-icon" />
|
||||||
<h3>{t('logs.signatures')}</h3>
|
<h3>{t('logs.signatures')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-grid">
|
<div className="form-grid signature-grid">
|
||||||
<div className="input-group">
|
<SignaturePad
|
||||||
<label>{t('logs.sign_skipper')}</label>
|
id="sign-skipper"
|
||||||
<input
|
label={t('logs.sign_skipper')}
|
||||||
type="text"
|
|
||||||
placeholder="e.g. MARKUS SKIPPER"
|
|
||||||
className="input-text"
|
|
||||||
value={signSkipper}
|
value={signSkipper}
|
||||||
onChange={(e) => setSignSkipper(e.target.value)}
|
onChange={setSignSkipper}
|
||||||
disabled={saving || readOnly}
|
disabled={saving}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
<SignaturePad
|
||||||
<label>{t('logs.sign_crew')}</label>
|
id="sign-crew"
|
||||||
<input
|
label={t('logs.sign_crew')}
|
||||||
type="text"
|
|
||||||
placeholder="e.g. JAN MATE"
|
|
||||||
className="input-text"
|
|
||||||
value={signCrew}
|
value={signCrew}
|
||||||
onChange={(e) => setSignCrew(e.target.value)}
|
onChange={setSignCrew}
|
||||||
disabled={saving || readOnly}
|
disabled={saving}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save Controls */}
|
{/* Save Controls */}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(null)
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(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<HTMLCanvasElement>) => {
|
||||||
|
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<HTMLCanvasElement>) => {
|
||||||
|
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<HTMLCanvasElement>) => {
|
||||||
|
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<HTMLCanvasElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="input-group signature-pad-group">
|
||||||
|
<label htmlFor={id}>{label}</label>
|
||||||
|
<div className="signature-legacy-text">{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="input-group signature-pad-group">
|
||||||
|
<div className="signature-pad-header">
|
||||||
|
<label htmlFor={id}>{label}</label>
|
||||||
|
{interactive && (
|
||||||
|
<button type="button" className="signature-pad-clear" onClick={handleClear}>
|
||||||
|
<Eraser size={14} />
|
||||||
|
{t('logs.sign_clear')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`signature-pad ${readOnly ? 'readonly' : ''} ${disabled ? 'disabled' : ''}`}
|
||||||
|
>
|
||||||
|
{readOnly && value && isSignatureImage(value) ? (
|
||||||
|
<img src={value} alt={label} className="signature-pad-image" />
|
||||||
|
) : (
|
||||||
|
<canvas
|
||||||
|
id={id}
|
||||||
|
ref={canvasRef}
|
||||||
|
className="signature-pad-canvas"
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={finishStroke}
|
||||||
|
onPointerLeave={finishStroke}
|
||||||
|
onPointerCancel={finishStroke}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{interactive && showHint && (
|
||||||
|
<span className="signature-pad-hint">{t('logs.sign_hint')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -113,8 +113,10 @@
|
|||||||
"evening": "Stand abends",
|
"evening": "Stand abends",
|
||||||
"consumption": "Tagesverbrauch",
|
"consumption": "Tagesverbrauch",
|
||||||
"signatures": "Unterschriften / Freigabe",
|
"signatures": "Unterschriften / Freigabe",
|
||||||
"sign_skipper": "Skipper (Blockschrift)",
|
"sign_skipper": "Skipper-Unterschrift",
|
||||||
"sign_crew": "Crew-Mitglied (Blockschrift)",
|
"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!",
|
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
|
||||||
"back_to_list": "Zurück zur Journal-Liste",
|
"back_to_list": "Zurück zur Journal-Liste",
|
||||||
"save": "Logbuchseite speichern",
|
"save": "Logbuchseite speichern",
|
||||||
|
|||||||
@@ -113,8 +113,10 @@
|
|||||||
"evening": "Evening Level",
|
"evening": "Evening Level",
|
||||||
"consumption": "Consumption",
|
"consumption": "Consumption",
|
||||||
"signatures": "Signatures / Sign-Off",
|
"signatures": "Signatures / Sign-Off",
|
||||||
"sign_skipper": "Skipper Signature",
|
"sign_skipper": "Skipper signature",
|
||||||
"sign_crew": "Crew 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!",
|
"no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!",
|
||||||
"back_to_list": "Back to Journal List",
|
"back_to_list": "Back to Journal List",
|
||||||
"save": "Save Logbook Page",
|
"save": "Save Logbook Page",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { db } from './db.js'
|
|||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { decryptJson } from './crypto.js'
|
import { decryptJson } from './crypto.js'
|
||||||
|
import { formatSignatureForExport } from '../utils/signatures.js'
|
||||||
|
|
||||||
function escapeCsvValue(val: string | number | undefined | null): string {
|
function escapeCsvValue(val: string | number | undefined | null): string {
|
||||||
if (val === null || val === undefined) return '';
|
if (val === null || val === undefined) return '';
|
||||||
@@ -93,8 +94,8 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
const travelDay = entry.dayOfTravel || '';
|
const travelDay = entry.dayOfTravel || '';
|
||||||
const dep = entry.departure || '';
|
const dep = entry.departure || '';
|
||||||
const dest = entry.destination || '';
|
const dest = entry.destination || '';
|
||||||
const signS = entry.signSkipper || '';
|
const signS = formatSignatureForExport(entry.signSkipper);
|
||||||
const signC = entry.signCrew || '';
|
const signC = formatSignatureForExport(entry.signCrew);
|
||||||
const fwM = entry.freshwater?.morning ?? '';
|
const fwM = entry.freshwater?.morning ?? '';
|
||||||
const fwR = entry.freshwater?.refilled ?? '';
|
const fwR = entry.freshwater?.refilled ?? '';
|
||||||
const fwE = entry.freshwater?.evening ?? '';
|
const fwE = entry.freshwater?.evening ?? '';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from './db.js'
|
|||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { decryptJson } from './crypto.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<jsPDF> {
|
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
||||||
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
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, sigY + rowHeight * 1.5, sigX + 157, sigY + rowHeight * 1.5);
|
||||||
doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
|
doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
|
||||||
|
|
||||||
doc.text('Skipper Unterschrift (in Blockschrift):', sigX + 2, sigY + 4.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.setFont('Helvetica', 'normal');
|
||||||
doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2);
|
doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2);
|
||||||
|
}
|
||||||
|
|
||||||
doc.setFont('Helvetica', 'bold');
|
doc.setFont('Helvetica', 'bold');
|
||||||
doc.text('Crew Unterschrift (in Blockschrift):', sigX + 80.5, sigY + 4.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.setFont('Helvetica', 'normal');
|
||||||
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
|
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
|
||||||
|
}
|
||||||
|
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user