Files
kapteins-daagbok/client/src/components/SignatureSection.tsx
T
elpatron 658bc6c0c9 feat(logs): Ereignis-Uhrzeit vorbelegen und 24h-Format vereinheitlichen
Neue Ereignisse starten mit der aktuellen Uhrzeit; Datums-/Zeitanzeigen und Zeit-Picker nutzen durchgängig das 24-Stunden-Format.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 10:57:47 +02:00

276 lines
8.0 KiB
TypeScript

import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Check } from 'lucide-react'
import SignaturePad from './SignaturePad.tsx'
import PasskeySignButton from './PasskeySignButton.tsx'
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
import { isPasskeySignature, getSignaturePayload, getSignatureAttribution } from '../utils/signatures.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
type SignatureMode = 'passkey' | 'classic'
interface SignatureSectionProps {
readOnly?: boolean
disabled?: boolean
isOnline: boolean
canSignSkipper: boolean
canSignCrew: boolean
signSkipper: SignatureValue | ''
signCrew: SignatureValue | ''
skipperSignatureValid: boolean
crewSignatureValid: boolean
onSignSkipperChange: (value: SignatureValue | '') => void
onSignCrewChange: (value: SignatureValue | '') => void
onPasskeySignSkipper: () => Promise<void>
onPasskeySignCrew: () => Promise<void>
onBeforeSign?: () => Promise<boolean>
}
function SignerAttributionBadge({ value }: { value: SignatureValue | '' }) {
const { t, i18n } = useTranslation()
const attribution = getSignatureAttribution(value)
if (!attribution) return null
const formattedDate = formatAppDateTime(attribution.signedAt, i18n.language)
return (
<div className="passkey-sign-badge valid signature-attribution-badge">
<span>{t('logs.sign_passkey_signed', { username: attribution.username })}</span>
<span className="passkey-sign-date">{formattedDate}</span>
</div>
)
}
function padValue(value: SignatureValue | ''): string {
return getSignaturePayload(value)
}
function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode {
if (isPasskeySignature(value)) return 'passkey'
if (getSignaturePayload(value)) return 'classic'
return passkeyAvailable ? 'passkey' : 'classic'
}
interface RoleSignatureBlockProps {
roleLabel: string
passkeyLabel: string
padId: string
value: SignatureValue | ''
passkeySignature?: PasskeySignature
signatureValid: boolean
showPasskey: boolean
readOnly: boolean
disabled: boolean
classicHint?: string
offlineHint?: string
onChange: (value: SignatureValue | '') => void
onPasskeySign: () => Promise<void>
onBeforeSign?: () => Promise<boolean>
}
function RoleSignatureBlock({
roleLabel,
passkeyLabel,
padId,
value,
passkeySignature,
signatureValid,
showPasskey,
readOnly,
disabled,
classicHint,
offlineHint,
onChange,
onPasskeySign,
onBeforeSign
}: RoleSignatureBlockProps) {
const { t } = useTranslation()
const [mode, setMode] = useState<SignatureMode>(() => modeFromValue(value, showPasskey))
useEffect(() => {
setMode(modeFromValue(value, showPasskey))
}, [value, showPasskey])
const switchToClassic = () => {
setMode('classic')
if (isPasskeySignature(value)) onChange('')
}
const switchToPasskey = () => {
setMode('passkey')
if (value && !isPasskeySignature(value)) onChange('')
}
const handlePadChange = (next: string) => {
setMode('classic')
onChange(next)
}
if (readOnly) {
if (isPasskeySignature(value)) {
return (
<div className="signature-role-block">
<PasskeySignButton
label={passkeyLabel}
signature={value}
signatureValid={signatureValid}
disabled={disabled}
canSign={false}
onSign={onPasskeySign}
/>
</div>
)
}
return (
<div className="signature-role-block">
<SignerAttributionBadge value={value} />
<SignaturePad
id={padId}
label={roleLabel}
value={padValue(value)}
onChange={() => {}}
disabled={disabled}
readOnly
/>
</div>
)
}
const showPasskeyPanel = showPasskey && mode === 'passkey'
const showClassicPanel = !showPasskey || mode === 'classic'
return (
<div className="signature-role-block">
{showPasskey && (
<div className="signature-mode-toggle" role="tablist" aria-label={passkeyLabel}>
<button
type="button"
role="tab"
aria-selected={mode === 'passkey'}
className={`signature-mode-btn ${mode === 'passkey' ? 'active' : ''}`}
onClick={switchToPasskey}
>
{t('logs.sign_mode_passkey')}
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'classic'}
className={`signature-mode-btn ${mode === 'classic' ? 'active' : ''}`}
onClick={switchToClassic}
>
{t('logs.sign_mode_classic')}
</button>
</div>
)}
{showPasskeyPanel && (
<PasskeySignButton
label={passkeyLabel}
signature={passkeySignature}
signatureValid={signatureValid}
disabled={disabled}
canSign
onSign={onPasskeySign}
onClear={passkeySignature ? switchToClassic : undefined}
/>
)}
{showClassicPanel && (
<>
<SignerAttributionBadge value={value} />
<SignaturePad
id={padId}
label={roleLabel}
value={padValue(value)}
onChange={handlePadChange}
disabled={disabled}
readOnly={false}
onBeforeSign={onBeforeSign}
/>
{classicHint && !passkeySignature && (
<p className="signature-hint">{classicHint}</p>
)}
</>
)}
{offlineHint && !showPasskey && (
<p className="signature-hint">{offlineHint}</p>
)}
</div>
)
}
export default function SignatureSection({
readOnly = false,
disabled = false,
isOnline,
canSignSkipper,
canSignCrew,
signSkipper,
signCrew,
skipperSignatureValid,
crewSignatureValid,
onSignSkipperChange,
onSignCrewChange,
onPasskeySignSkipper,
onPasskeySignCrew,
onBeforeSign
}: SignatureSectionProps) {
const { t } = useTranslation()
const showSkipperPasskey = canSignSkipper && isOnline
const showCrewPasskey = canSignCrew && isOnline
const hasSignature = !!(signSkipper || signCrew)
return (
<div className="form-card">
<div className="form-header">
<Check size={20} className="form-icon" />
<h3>{t('logs.signatures')}</h3>
</div>
{!readOnly && (
<p className={`signature-lock-notice ${hasSignature ? 'locked' : ''}`}>
{hasSignature ? t('logs.sign_lock_active') : t('logs.sign_lock_notice')}
</p>
)}
<div className="form-grid signature-grid">
<RoleSignatureBlock
roleLabel={t('logs.sign_skipper')}
passkeyLabel={t('logs.sign_skipper')}
padId="sign-skipper"
value={signSkipper}
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
signatureValid={skipperSignatureValid}
showPasskey={showSkipperPasskey}
readOnly={readOnly || !canSignSkipper}
disabled={disabled}
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined}
onChange={onSignSkipperChange}
onPasskeySign={onPasskeySignSkipper}
onBeforeSign={onBeforeSign}
/>
<RoleSignatureBlock
roleLabel={t('logs.sign_crew')}
passkeyLabel={t('logs.sign_crew')}
padId="sign-crew"
value={signCrew}
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
signatureValid={crewSignatureValid}
showPasskey={showCrewPasskey}
readOnly={readOnly || !canSignCrew}
disabled={disabled}
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
onChange={onSignCrewChange}
onPasskeySign={onPasskeySignCrew}
onBeforeSign={onBeforeSign}
/>
</div>
</div>
)
}