9d22cb61c7
Cache plaintext list metadata on entry save so the journal list avoids full decrypt per row, and batch sync pull writes with main-thread yields. Co-authored-by: Cursor <cursoragent@cursor.com>
2205 lines
78 KiB
TypeScript
2205 lines
78 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { db } from '../services/db.js'
|
|
import { getActiveMasterKey } from '../services/auth.js'
|
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
|
import { encryptJson, decryptJson } from '../services/crypto.js'
|
|
import { syncLogbook } from '../services/sync.js'
|
|
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
|
|
import { getErrorMessage } from '../utils/errors.js'
|
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
|
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
|
import PhotoCapture from './PhotoCapture.tsx'
|
|
import SignatureSection from './SignatureSection.tsx'
|
|
import EntryCrewSection from './EntryCrewSection.tsx'
|
|
import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js'
|
|
import { entryCrewFromPreviousEntry } from '../utils/personSnapshots.js'
|
|
import TrackMap from './TrackMap.tsx'
|
|
import { useDialog } from './ModalDialog.tsx'
|
|
import {
|
|
normalizeSignature,
|
|
fingerprintSignature,
|
|
normalizedSerializedSignature,
|
|
isPasskeySignature,
|
|
isClassicSignature,
|
|
createClassicSignature,
|
|
isSignatureValidForEntry,
|
|
hasAnySignature
|
|
} from '../utils/signatures.js'
|
|
import type { SignatureValue } from '../types/signatures.js'
|
|
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
|
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
|
import CourseDialInput from './CourseDialInput.tsx'
|
|
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
|
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
|
import { signLogEntry } from '../services/entrySigning.js'
|
|
import { putEntryRecord } from '../utils/entryListCache.js'
|
|
import { getLogbookAccess } from '../services/logbookAccess.js'
|
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
|
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
|
import {
|
|
getDecryptedTrack,
|
|
saveUploadedTrack,
|
|
deleteTrack,
|
|
downloadTrackFile,
|
|
parseTrackFile,
|
|
type SavedTrack,
|
|
type TrackWaypoint
|
|
} from '../services/trackUpload.js'
|
|
import NmeaImportWizard from './NmeaImportWizard.tsx'
|
|
import {
|
|
deleteNmeaArchive,
|
|
downloadNmeaArchive,
|
|
getNmeaArchive,
|
|
type NmeaArchiveRecord
|
|
} from '../services/nmeaArchive.js'
|
|
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
|
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
|
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
|
import TankLiterInput from './TankLiterInput.tsx'
|
|
import MetricRangeInput from './MetricRangeInput.tsx'
|
|
import {
|
|
formatHeelDeg,
|
|
formatPressureHpa,
|
|
formatSeaState,
|
|
formatVisibilityMeters,
|
|
HEEL_MAX_DEG,
|
|
HEEL_MIN_DEG,
|
|
parseHeelDeg,
|
|
parsePressureHpa,
|
|
parseSeaState,
|
|
parseVisibilityMeters,
|
|
PRESSURE_DEFAULT_HPA,
|
|
PRESSURE_MAX_HPA,
|
|
PRESSURE_MIN_HPA,
|
|
SEA_STATE_MAX,
|
|
SEA_STATE_MIN,
|
|
VISIBILITY_STEPS_M
|
|
} from '../utils/weatherMetrics.js'
|
|
import {
|
|
computeEveningTankMaxLiters,
|
|
computeRefilledTankMaxLiters,
|
|
extractTankCapacitiesFromYacht,
|
|
formatTankLitersForInput,
|
|
type VesselTankCapacities
|
|
} from '../utils/tankCapacity.js'
|
|
|
|
function emptyTankLevels() {
|
|
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
|
}
|
|
|
|
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
|
|
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
|
|
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
|
|
const gw = decrypted.greywater as { level?: number } | undefined
|
|
const trackDistance = decrypted.trackDistanceNm
|
|
const trackSpeedMax = decrypted.trackSpeedMaxKn
|
|
const trackSpeedAvg = decrypted.trackSpeedAvgKn
|
|
const motorHoursRaw = decrypted.motorHours
|
|
|
|
const payload = buildLogEntryPayload({
|
|
date: String(decrypted.date || ''),
|
|
dayOfTravel: String(decrypted.dayOfTravel || ''),
|
|
departure: String(decrypted.departure || ''),
|
|
destination: String(decrypted.destination || ''),
|
|
freshwater: {
|
|
morning: fw.morning || 0,
|
|
refilled: fw.refilled || 0,
|
|
evening: fw.evening || 0,
|
|
consumption: fw.consumption ?? 0
|
|
},
|
|
fuel: {
|
|
morning: fuel.morning || 0,
|
|
refilled: fuel.refilled || 0,
|
|
evening: fuel.evening || 0,
|
|
consumption: fuel.consumption ?? 0
|
|
},
|
|
greywater: gw ? { level: gw.level || 0 } : undefined,
|
|
trackDistanceNm:
|
|
trackDistance != null && trackDistance !== ''
|
|
? parseFloat(String(trackDistance))
|
|
: undefined,
|
|
trackSpeedMaxKn:
|
|
trackSpeedMax != null && trackSpeedMax !== ''
|
|
? parseFloat(String(trackSpeedMax))
|
|
: undefined,
|
|
trackSpeedAvgKn:
|
|
trackSpeedAvg != null && trackSpeedAvg !== ''
|
|
? parseFloat(String(trackSpeedAvg))
|
|
: undefined,
|
|
motorHours:
|
|
motorHoursRaw != null && motorHoursRaw !== ''
|
|
? parseFloat(String(motorHoursRaw))
|
|
: undefined,
|
|
events: (decrypted.events as LogEventPayload[]) || [],
|
|
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
|
|
})
|
|
|
|
return JSON.stringify({
|
|
...payload,
|
|
signSkipper: fingerprintSignature(decrypted.signSkipper),
|
|
signCrew: fingerprintSignature(decrypted.signCrew)
|
|
})
|
|
}
|
|
|
|
interface LogEntryEditorProps {
|
|
entryId: string
|
|
logbookId: string
|
|
onBack: () => void
|
|
readOnly?: boolean
|
|
preloadedEntry?: any
|
|
preloadedPhotos?: any[]
|
|
preloadedTrack?: any
|
|
preloadedYacht?: any
|
|
}
|
|
|
|
interface LogEvent extends LogEventPayload {}
|
|
|
|
export default function LogEntryEditor({
|
|
entryId,
|
|
logbookId,
|
|
onBack,
|
|
readOnly = false,
|
|
preloadedEntry,
|
|
preloadedPhotos,
|
|
preloadedTrack,
|
|
preloadedYacht
|
|
}: LogEntryEditorProps) {
|
|
const { t, i18n } = useTranslation()
|
|
const { showAlert, showConfirm } = useDialog()
|
|
const showAlertRef = useRef(showAlert)
|
|
showAlertRef.current = showAlert
|
|
|
|
// General details state
|
|
const [date, setDate] = useState('')
|
|
const [dayOfTravel, setDayOfTravel] = useState('')
|
|
const [yachtSails, setYachtSails] = useState<string[]>([])
|
|
const [departure, setDeparture] = useState('')
|
|
const [destination, setDestination] = useState('')
|
|
|
|
// Freshwater state
|
|
const [fwMorning, setFwMorning] = useState('0')
|
|
const [fwRefilled, setFwRefilled] = useState('0')
|
|
const [fwEvening, setFwEvening] = useState('0')
|
|
const [fwConsumption, setFwConsumption] = useState('0')
|
|
|
|
// Fuel state
|
|
const [fuelMorning, setFuelMorning] = useState('0')
|
|
const [fuelRefilled, setFuelRefilled] = useState('0')
|
|
const [fuelEvening, setFuelEvening] = useState('0')
|
|
const [fuelConsumption, setFuelConsumption] = useState('0')
|
|
|
|
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
|
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
|
|
|
const [entryCrew, setEntryCrew] = useState<EntryCrewFields>(emptyEntryCrewFields())
|
|
|
|
// Signatures
|
|
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
|
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
|
const [canSignSkipper, setCanSignSkipper] = useState(false)
|
|
const [canSignCrew, setCanSignCrew] = useState(false)
|
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
|
const [entryHash, setEntryHash] = useState('')
|
|
|
|
// GPS track stats (from uploaded track)
|
|
const [trackDistanceNm, setTrackDistanceNm] = useState('')
|
|
const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('')
|
|
const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('')
|
|
|
|
// Motor hours under engine propulsion (per travel day)
|
|
const [motorHours, setMotorHours] = useState('')
|
|
|
|
// Events list state
|
|
const [events, setEvents] = useState<LogEvent[]>([])
|
|
|
|
// Add Event Form State
|
|
const [evTime, setEvTime] = useState(() => currentLocalTimeHHMM())
|
|
const [evMgk, setEvMgk] = useState('')
|
|
const [evRwk, setEvRwk] = useState('')
|
|
const [evWindPressure, setEvWindPressure] = useState('')
|
|
const [evWindDirection, setEvWindDirection] = useState('')
|
|
const [evWindStrength, setEvWindStrength] = useState('')
|
|
const [evSeaState, setEvSeaState] = useState('')
|
|
const [evVisibility, setEvVisibility] = useState('')
|
|
const [evWeatherIcon, setEvWeatherIcon] = useState('')
|
|
const [evCurrent, setEvCurrent] = useState('')
|
|
const [evHeel, setEvHeel] = useState('')
|
|
const [evSailsOrMotor, setEvSailsOrMotor] = useState('')
|
|
const [sailsPickerExpanded, setSailsPickerExpanded] = useState(false)
|
|
const [evLogReading, setEvLogReading] = useState('')
|
|
const [evDistance, setEvDistance] = useState('')
|
|
const [evGpsLat, setEvGpsLat] = useState('')
|
|
const [evGpsLng, setEvGpsLng] = useState('')
|
|
const [evRemarks, setEvRemarks] = useState('')
|
|
const [evLocationName, setEvLocationName] = useState('')
|
|
const [activeCourseTab, setActiveCourseTab] = useState<'mgk' | 'rwk'>('mgk')
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [exporting, setExporting] = useState(false)
|
|
const [success, setSuccess] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [weatherLoading, setWeatherLoading] = useState(false)
|
|
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
|
|
|
|
// Track file upload
|
|
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
|
|
const [dragOver, setDragOver] = useState(false)
|
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
|
const [nmeaWizardOpen, setNmeaWizardOpen] = useState(false)
|
|
const [nmeaArchive, setNmeaArchive] = useState<NmeaArchiveRecord | null>(null)
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
|
const lockedContentHashRef = useRef<string | null>(null)
|
|
const contentReadyRef = useRef(false)
|
|
const lastSignatureAlertHashRef = useRef<string | null>(null)
|
|
const skipCrewSignClearRef = useRef(false)
|
|
const entryHashSeqRef = useRef(0)
|
|
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
|
|
|
|
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
|
|
const stats = computeTrackStats(waypoints)
|
|
if (!stats) return
|
|
const formatted = formatTrackStats(stats)
|
|
setTrackDistanceNm(formatted.distanceNm)
|
|
setTrackSpeedMaxKn(formatted.speedMaxKn)
|
|
setTrackSpeedAvgKn(formatted.speedAvgKn)
|
|
}
|
|
|
|
const loadTrackStatsFromEntry = (entry: any) => {
|
|
if (entry?.trackDistanceNm != null && entry.trackDistanceNm !== '') {
|
|
setTrackDistanceNm(String(entry.trackDistanceNm))
|
|
}
|
|
if (entry?.trackSpeedMaxKn != null && entry.trackSpeedMaxKn !== '') {
|
|
setTrackSpeedMaxKn(String(entry.trackSpeedMaxKn))
|
|
}
|
|
if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') {
|
|
setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn))
|
|
}
|
|
if (entry?.motorHours != null && entry.motorHours !== '') {
|
|
setMotorHours(String(entry.motorHours))
|
|
} else {
|
|
setMotorHours('')
|
|
}
|
|
}
|
|
|
|
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
|
|
return buildLogEntryPayload({
|
|
date,
|
|
dayOfTravel,
|
|
departure,
|
|
destination,
|
|
freshwater: {
|
|
morning: parseFloat(fwMorning) || 0,
|
|
refilled: parseFloat(fwRefilled) || 0,
|
|
evening: parseFloat(fwEvening) || 0,
|
|
consumption: parseFloat(fwConsumption) || 0
|
|
},
|
|
fuel: {
|
|
morning: parseFloat(fuelMorning) || 0,
|
|
refilled: parseFloat(fuelRefilled) || 0,
|
|
evening: parseFloat(fuelEvening) || 0,
|
|
consumption: parseFloat(fuelConsumption) || 0
|
|
},
|
|
greywater: { level: parseFloat(greywaterLevel) || 0 },
|
|
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
|
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
|
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
|
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
|
|
events: eventsOverride ?? events,
|
|
entryCrew
|
|
})
|
|
}, [
|
|
date, dayOfTravel, departure, destination,
|
|
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
|
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
|
greywaterLevel,
|
|
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
|
events,
|
|
entryCrew
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (readOnly || loading || !date) return
|
|
const timer = window.setTimeout(() => {
|
|
void saveEntryDraft(logbookId, entryId, buildPayloadForSigning())
|
|
}, 4000)
|
|
return () => window.clearTimeout(timer)
|
|
}, [readOnly, loading, logbookId, entryId, buildPayloadForSigning, date])
|
|
|
|
const fuelPerMotorHour = useMemo(
|
|
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
|
|
[fuelConsumption, motorHours]
|
|
)
|
|
|
|
const tankCapacityTooltip = t('logs.tank_capacity_tooltip')
|
|
|
|
const fwRefilledMax = useMemo(
|
|
() => computeRefilledTankMaxLiters(fwMorning, tankCapacities.freshwaterCapacityL),
|
|
[fwMorning, tankCapacities.freshwaterCapacityL]
|
|
)
|
|
|
|
const fwEveningMax = useMemo(
|
|
() =>
|
|
computeEveningTankMaxLiters(
|
|
fwMorning,
|
|
fwRefilled,
|
|
tankCapacities.freshwaterCapacityL
|
|
),
|
|
[fwMorning, fwRefilled, tankCapacities.freshwaterCapacityL]
|
|
)
|
|
|
|
const fuelRefilledMax = useMemo(
|
|
() => computeRefilledTankMaxLiters(fuelMorning, tankCapacities.fuelCapacityL),
|
|
[fuelMorning, tankCapacities.fuelCapacityL]
|
|
)
|
|
|
|
const fuelEveningMax = useMemo(
|
|
() =>
|
|
computeEveningTankMaxLiters(
|
|
fuelMorning,
|
|
fuelRefilled,
|
|
tankCapacities.fuelCapacityL
|
|
),
|
|
[fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL]
|
|
)
|
|
|
|
const currentFingerprint = useMemo(() => {
|
|
const payload = buildPayloadForSigning()
|
|
return JSON.stringify({
|
|
...payload,
|
|
signSkipper: fingerprintSignature(signSkipper),
|
|
signCrew: fingerprintSignature(signCrew)
|
|
})
|
|
}, [buildPayloadForSigning, signSkipper, signCrew])
|
|
|
|
const buildEventFromForm = (): LogEvent =>
|
|
normalizeLogEvent({
|
|
time: evTime,
|
|
mgk: evMgk,
|
|
rwk: evRwk,
|
|
windPressure: evWindPressure,
|
|
windDirection: evWindDirection,
|
|
windStrength: evWindStrength,
|
|
seaState: evSeaState,
|
|
visibility: evVisibility,
|
|
weatherIcon: evWeatherIcon,
|
|
current: evCurrent,
|
|
heel: evHeel,
|
|
sailsOrMotor: evSailsOrMotor,
|
|
logReading: evLogReading,
|
|
distance: evDistance,
|
|
gpsLat: evGpsLat,
|
|
gpsLng: evGpsLng,
|
|
remarks: evRemarks
|
|
})
|
|
|
|
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
|
|
if (editingEventIndex !== null) {
|
|
return sortLogEventsByTime(events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev)))
|
|
}
|
|
return sortLogEventsByTime([...events, eventData])
|
|
}
|
|
|
|
const hasPendingEventForm = useMemo(() => {
|
|
return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events)
|
|
}, [
|
|
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
|
|
evVisibility, evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
|
evGpsLat, evGpsLng, evRemarks, editingEventIndex, events
|
|
])
|
|
|
|
const isDirty = savedFingerprint !== null && (
|
|
currentFingerprint !== savedFingerprint || hasPendingEventForm
|
|
)
|
|
|
|
const saveBeforeLeaveRef = useRef<(() => Promise<void>) | null>(null)
|
|
const invokeSaveBeforeLeave = useCallback(async () => {
|
|
if (saveBeforeLeaveRef.current) await saveBeforeLeaveRef.current()
|
|
}, [])
|
|
|
|
const { confirmLeave } = useRegisterUnsavedChanges(
|
|
`log-entry-${entryId}`,
|
|
!readOnly && !loading && isDirty,
|
|
invokeSaveBeforeLeave
|
|
)
|
|
|
|
const handleBack = async () => {
|
|
if (!(await confirmLeave())) return
|
|
onBack()
|
|
}
|
|
|
|
const persistEntryToDb = useCallback(async (
|
|
options?: LogEvent[] | {
|
|
eventsOverride?: LogEvent[]
|
|
signSkipper?: SignatureValue | ''
|
|
signCrew?: SignatureValue | ''
|
|
}
|
|
) => {
|
|
if (readOnly) return
|
|
|
|
const normalized = Array.isArray(options) ? { eventsOverride: options } : (options ?? {})
|
|
const eventsOverride = normalized.eventsOverride
|
|
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
|
|
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
|
|
|
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
|
|
|
const entryData = {
|
|
...buildPayloadForSigning(eventsOverride),
|
|
signSkipper: normalizedSerializedSignature(skipperToSave),
|
|
signCrew: normalizedSerializedSignature(crewToSave)
|
|
}
|
|
|
|
const encrypted = await encryptJson(entryData, masterKey)
|
|
const now = new Date().toISOString()
|
|
|
|
await putEntryRecord(
|
|
{
|
|
payloadId: entryId,
|
|
logbookId,
|
|
encryptedData: encrypted.ciphertext,
|
|
iv: encrypted.iv,
|
|
tag: encrypted.tag,
|
|
updatedAt: now
|
|
},
|
|
entryData
|
|
)
|
|
|
|
await db.syncQueue.put({
|
|
action: 'update',
|
|
type: 'entry',
|
|
payloadId: entryId,
|
|
logbookId,
|
|
data: JSON.stringify(encrypted),
|
|
updatedAt: now
|
|
})
|
|
|
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
|
|
|
setSavedFingerprint(JSON.stringify({
|
|
...buildPayloadForSigning(eventsOverride),
|
|
signSkipper: fingerprintSignature(skipperToSave),
|
|
signCrew: fingerprintSignature(crewToSave)
|
|
}))
|
|
|
|
const hash = await hashEntryForSigning(buildPayloadForSigning(eventsOverride))
|
|
entryHashSeqRef.current += 1
|
|
setEntryHash(hash)
|
|
lockedContentHashRef.current = hasAnySignature(skipperToSave, crewToSave) ? hash : null
|
|
}, [
|
|
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
|
|
])
|
|
|
|
useEffect(() => {
|
|
const handleOnline = () => setIsOnline(true)
|
|
const handleOffline = () => setIsOnline(false)
|
|
window.addEventListener('online', handleOnline)
|
|
window.addEventListener('offline', handleOffline)
|
|
return () => {
|
|
window.removeEventListener('online', handleOnline)
|
|
window.removeEventListener('offline', handleOffline)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
getLogbookAccess(logbookId).then((access) => {
|
|
if (!access) return
|
|
setCanSignSkipper(access.isOwner)
|
|
setCanSignCrew(
|
|
access.role === 'WRITE' ||
|
|
(access.isOwner && access.writeCollaboratorCount === 0)
|
|
)
|
|
})
|
|
}, [logbookId])
|
|
|
|
useEffect(() => {
|
|
const seq = ++entryHashSeqRef.current
|
|
let cancelled = false
|
|
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
|
|
if (cancelled || seq !== entryHashSeqRef.current) return
|
|
setEntryHash(hash)
|
|
})
|
|
return () => { cancelled = true }
|
|
}, [buildPayloadForSigning])
|
|
|
|
useEffect(() => {
|
|
contentReadyRef.current = false
|
|
if (loading) return
|
|
const timer = window.setTimeout(() => {
|
|
contentReadyRef.current = true
|
|
}, 0)
|
|
return () => window.clearTimeout(timer)
|
|
}, [loading])
|
|
|
|
useEffect(() => {
|
|
if (!entryHash || !contentReadyRef.current || readOnly) return
|
|
|
|
const hasSig = hasAnySignature(signSkipper, signCrew)
|
|
if (!hasSig) {
|
|
lockedContentHashRef.current = null
|
|
return
|
|
}
|
|
|
|
if (!lockedContentHashRef.current) {
|
|
lockedContentHashRef.current = entryHash
|
|
return
|
|
}
|
|
|
|
if (entryHash !== lockedContentHashRef.current) {
|
|
lockedContentHashRef.current = null
|
|
const hadSkipper = !!signSkipper
|
|
const hadCrew = !!signCrew
|
|
const skipperOnly = skipCrewSignClearRef.current
|
|
skipCrewSignClearRef.current = false
|
|
if (hadSkipper) setSignSkipper('')
|
|
if (hadCrew && !skipperOnly) setSignCrew('')
|
|
if (lastSignatureAlertHashRef.current !== entryHash && (hadSkipper || (hadCrew && !skipperOnly))) {
|
|
lastSignatureAlertHashRef.current = entryHash
|
|
void showAlertRef.current(
|
|
skipperOnly ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'),
|
|
skipperOnly ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title')
|
|
)
|
|
}
|
|
}
|
|
}, [entryHash, signSkipper, signCrew, readOnly, t])
|
|
|
|
const confirmSignWarning = useCallback(async (): Promise<boolean> => {
|
|
return showConfirm(
|
|
t('logs.sign_lock_warning'),
|
|
t('logs.sign_lock_warning_title'),
|
|
t('logs.sign_proceed'),
|
|
t('logs.sign_cancel')
|
|
)
|
|
}, [showConfirm, t])
|
|
|
|
const skipperSignatureValid = !isPasskeySignature(signSkipper) || isSignatureValidForEntry(signSkipper, entryHash)
|
|
const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash)
|
|
|
|
const handlePasskeySignSkipper = async () => {
|
|
if (!canSignSkipper) return
|
|
const confirmed = await confirmSignWarning()
|
|
if (!confirmed) return
|
|
|
|
const hash = await hashEntryForSigning(buildPayloadForSigning())
|
|
const signature = await signLogEntry({
|
|
logbookId,
|
|
entryId,
|
|
entryHash: hash,
|
|
role: 'skipper'
|
|
})
|
|
setSignSkipper(signature)
|
|
entryHashSeqRef.current += 1
|
|
setEntryHash(hash)
|
|
lockedContentHashRef.current = hash
|
|
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
|
}
|
|
|
|
const handlePasskeySignCrew = async () => {
|
|
if (!canSignCrew) return
|
|
const confirmed = await confirmSignWarning()
|
|
if (!confirmed) return
|
|
|
|
const hash = await hashEntryForSigning(buildPayloadForSigning())
|
|
const signature = await signLogEntry({
|
|
logbookId,
|
|
entryId,
|
|
entryHash: hash,
|
|
role: 'crew'
|
|
})
|
|
setSignCrew(signature)
|
|
entryHashSeqRef.current += 1
|
|
setEntryHash(hash)
|
|
lockedContentHashRef.current = hash
|
|
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
|
|
}
|
|
|
|
// Auto-calculate Freshwater Consumption
|
|
useEffect(() => {
|
|
const morning = parseFloat(fwMorning) || 0
|
|
const refilled = parseFloat(fwRefilled) || 0
|
|
const evening = parseFloat(fwEvening) || 0
|
|
const cons = morning + refilled - evening
|
|
setFwConsumption(cons >= 0 ? String(cons) : '0')
|
|
}, [fwMorning, fwRefilled, fwEvening])
|
|
|
|
// Auto-calculate Fuel Consumption
|
|
useEffect(() => {
|
|
const morning = parseFloat(fuelMorning) || 0
|
|
const refilled = parseFloat(fuelRefilled) || 0
|
|
const evening = parseFloat(fuelEvening) || 0
|
|
const cons = morning + refilled - evening
|
|
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
|
}, [fuelMorning, fuelRefilled, fuelEvening])
|
|
|
|
const fwRefilledNoCapacity =
|
|
(tankCapacities.freshwaterCapacityL ?? 0) > 0 && fwRefilledMax == null
|
|
const fuelRefilledNoCapacity =
|
|
(tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null
|
|
|
|
useEffect(() => {
|
|
const refilled = parseFloat(fwRefilled) || 0
|
|
if (fwRefilledMax == null) {
|
|
if (fwRefilledNoCapacity && refilled > 0) {
|
|
setFwRefilled(formatTankLitersForInput(0))
|
|
}
|
|
return
|
|
}
|
|
if (refilled > fwRefilledMax) {
|
|
setFwRefilled(formatTankLitersForInput(fwRefilledMax))
|
|
}
|
|
}, [fwRefilledMax, fwRefilled, fwRefilledNoCapacity])
|
|
|
|
useEffect(() => {
|
|
if (fwEveningMax == null) return
|
|
const evening = parseFloat(fwEvening) || 0
|
|
if (evening > fwEveningMax) {
|
|
setFwEvening(formatTankLitersForInput(fwEveningMax))
|
|
}
|
|
}, [fwEveningMax, fwEvening])
|
|
|
|
useEffect(() => {
|
|
const refilled = parseFloat(fuelRefilled) || 0
|
|
if (fuelRefilledMax == null) {
|
|
if (fuelRefilledNoCapacity && refilled > 0) {
|
|
setFuelRefilled(formatTankLitersForInput(0))
|
|
}
|
|
return
|
|
}
|
|
if (refilled > fuelRefilledMax) {
|
|
setFuelRefilled(formatTankLitersForInput(fuelRefilledMax))
|
|
}
|
|
}, [fuelRefilledMax, fuelRefilled, fuelRefilledNoCapacity])
|
|
|
|
useEffect(() => {
|
|
if (fuelEveningMax == null) return
|
|
const evening = parseFloat(fuelEvening) || 0
|
|
if (evening > fuelEveningMax) {
|
|
setFuelEvening(formatTankLitersForInput(fuelEveningMax))
|
|
}
|
|
}, [fuelEveningMax, fuelEvening])
|
|
|
|
// Load vessel sails and tank capacities
|
|
useEffect(() => {
|
|
async function loadYachtMeta() {
|
|
try {
|
|
const { resolveVesselForLogbook } = await import('../services/resolveVessel.js')
|
|
const vessel =
|
|
readOnly && preloadedYacht
|
|
? (preloadedYacht as Record<string, unknown>)
|
|
: await resolveVesselForLogbook(logbookId, { preloadedYacht: preloadedYacht ?? undefined })
|
|
if (!vessel) return
|
|
if (vessel.sails && Array.isArray(vessel.sails)) {
|
|
setYachtSails(vessel.sails)
|
|
}
|
|
setTankCapacities(extractTankCapacitiesFromYacht(vessel))
|
|
} catch (err) {
|
|
console.error('Failed to load vessel meta in editor:', err)
|
|
}
|
|
}
|
|
void loadYachtMeta()
|
|
}, [logbookId, preloadedYacht, readOnly])
|
|
|
|
// Load entry details
|
|
useEffect(() => {
|
|
async function loadEntry() {
|
|
setLoading(true)
|
|
setError(null)
|
|
setSavedFingerprint(null)
|
|
lockedContentHashRef.current = null
|
|
contentReadyRef.current = false
|
|
lastSignatureAlertHashRef.current = null
|
|
try {
|
|
if (readOnly && preloadedEntry) {
|
|
setDate(preloadedEntry.date || '')
|
|
setDayOfTravel(preloadedEntry.dayOfTravel || '')
|
|
setDeparture(preloadedEntry.departure || '')
|
|
setDestination(preloadedEntry.destination || '')
|
|
|
|
if (preloadedEntry.freshwater) {
|
|
setFwMorning(String(preloadedEntry.freshwater.morning || 0))
|
|
setFwRefilled(String(preloadedEntry.freshwater.refilled || 0))
|
|
setFwEvening(String(preloadedEntry.freshwater.evening || 0))
|
|
setFwConsumption(String(preloadedEntry.freshwater.consumption ?? 0))
|
|
}
|
|
if (preloadedEntry.fuel) {
|
|
setFuelMorning(String(preloadedEntry.fuel.morning || 0))
|
|
setFuelRefilled(String(preloadedEntry.fuel.refilled || 0))
|
|
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
|
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
|
|
}
|
|
if (preloadedEntry.greywater) {
|
|
setGreywaterLevel(String(preloadedEntry.greywater.level || 0))
|
|
} else {
|
|
setGreywaterLevel('0')
|
|
}
|
|
|
|
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
|
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
|
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
|
|
loadTrackStatsFromEntry(preloadedEntry)
|
|
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
|
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
|
return
|
|
}
|
|
|
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
|
|
|
const local = await db.entries.get(entryId)
|
|
if (local) {
|
|
const decrypted = await decryptJson(local.encryptedData, local.iv, local.tag, masterKey)
|
|
if (decrypted) {
|
|
setDate(decrypted.date || '')
|
|
setDayOfTravel(decrypted.dayOfTravel || '')
|
|
setDeparture(decrypted.departure || '')
|
|
setDestination(decrypted.destination || '')
|
|
|
|
if (decrypted.freshwater) {
|
|
setFwMorning(String(decrypted.freshwater.morning || 0))
|
|
setFwRefilled(String(decrypted.freshwater.refilled || 0))
|
|
setFwEvening(String(decrypted.freshwater.evening || 0))
|
|
setFwConsumption(String(decrypted.freshwater.consumption ?? 0))
|
|
}
|
|
if (decrypted.fuel) {
|
|
setFuelMorning(String(decrypted.fuel.morning || 0))
|
|
setFuelRefilled(String(decrypted.fuel.refilled || 0))
|
|
setFuelEvening(String(decrypted.fuel.evening || 0))
|
|
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
|
|
}
|
|
if (decrypted.greywater) {
|
|
setGreywaterLevel(String(decrypted.greywater.level || 0))
|
|
} else {
|
|
setGreywaterLevel('0')
|
|
}
|
|
|
|
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
|
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
|
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
|
|
loadTrackStatsFromEntry(decrypted)
|
|
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
|
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Failed to load entry details:', err)
|
|
setError(err.message || 'Decryption failed. Could not load entry details.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
loadEntry()
|
|
}, [entryId, preloadedEntry])
|
|
|
|
const loadTrack = async () => {
|
|
if (readOnly && preloadedTrack) {
|
|
setSavedTrack({
|
|
waypoints: preloadedTrack.waypoints ?? [],
|
|
gpxContent: preloadedTrack.gpxContent ?? '',
|
|
filename: preloadedTrack.filename ?? 'track.gpx',
|
|
fileType: preloadedTrack.fileType ?? 'gpx'
|
|
})
|
|
return
|
|
}
|
|
try {
|
|
const track = await getDecryptedTrack(entryId)
|
|
setSavedTrack(track)
|
|
} catch (e) {
|
|
console.warn('Failed to load track file:', e)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadTrack()
|
|
}, [entryId, preloadedTrack])
|
|
|
|
const loadNmeaArchive = async () => {
|
|
if (readOnly) return
|
|
try {
|
|
const archive = await getNmeaArchive(entryId)
|
|
setNmeaArchive(archive)
|
|
} catch {
|
|
setNmeaArchive(null)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadNmeaArchive()
|
|
}, [entryId, readOnly])
|
|
|
|
const handleNmeaImport = async (importedEvents: LogEventPayload[], waypoints?: TrackWaypoint[]) => {
|
|
setEvents((prev) => sortLogEventsByTime([...prev, ...importedEvents]))
|
|
if (waypoints && waypoints.length > 0) {
|
|
try {
|
|
const gpxLike = waypoints
|
|
.map((wp) => ` <trkpt lat="${wp.lat}" lon="${wp.lng}"><time>${new Date(wp.timestamp).toISOString()}</time></trkpt>`)
|
|
.join('\n')
|
|
const content = `<?xml version="1.0"?><gpx><trk><trkseg>\n${gpxLike}\n</trkseg></trk></gpx>`
|
|
await saveUploadedTrack(logbookId, entryId, content, waypoints, 'imported-from-nmea.nmea', 'nmea')
|
|
applyTrackStats(waypoints)
|
|
await loadTrack()
|
|
trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED)
|
|
} catch (err: unknown) {
|
|
console.warn('Failed to save NMEA track:', err)
|
|
}
|
|
}
|
|
await loadNmeaArchive()
|
|
}
|
|
|
|
const handleDeleteNmeaArchive = async () => {
|
|
if (!window.confirm(t('logs.nmea_archive_delete_confirm'))) return
|
|
await deleteNmeaArchive(entryId)
|
|
setNmeaArchive(null)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!savedTrack || savedTrack.waypoints.length < 2) return
|
|
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
|
|
applyTrackStats(savedTrack.waypoints)
|
|
}, [savedTrack, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn])
|
|
|
|
// Track file upload handlers
|
|
const handleFileUpload = async (file: File) => {
|
|
if (readOnly) return
|
|
setUploadError(null)
|
|
const reader = new FileReader()
|
|
reader.onload = async (e) => {
|
|
try {
|
|
const text = e.target?.result as string
|
|
if (!text) {
|
|
throw new Error('File is empty')
|
|
}
|
|
|
|
const { waypoints: parsedWps, type: fileType } = parseTrackFile(text, file.name)
|
|
|
|
if (parsedWps.length === 0) {
|
|
throw new Error('No coordinates found in file. Supported formats: GPX, KML, GeoJSON.')
|
|
}
|
|
|
|
await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
|
|
applyTrackStats(parsedWps)
|
|
await loadTrack()
|
|
trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED)
|
|
} catch (err: any) {
|
|
console.error('File parsing failed:', err)
|
|
setUploadError(err.message || 'Failed to parse track file.')
|
|
}
|
|
}
|
|
reader.onerror = () => {
|
|
setUploadError('Failed to read file.')
|
|
}
|
|
reader.readAsText(file)
|
|
}
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
handleFileUpload(e.target.files[0])
|
|
}
|
|
}
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setDragOver(true)
|
|
}
|
|
|
|
const handleDragLeave = () => {
|
|
setDragOver(false)
|
|
}
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setDragOver(false)
|
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
handleFileUpload(e.dataTransfer.files[0])
|
|
}
|
|
}
|
|
|
|
const handleDeleteTrack = async () => {
|
|
if (readOnly) return
|
|
if (!window.confirm(t('logs.gps_track_delete_confirm'))) {
|
|
return
|
|
}
|
|
try {
|
|
await deleteTrack(logbookId, entryId)
|
|
setSavedTrack(null)
|
|
setTrackDistanceNm('')
|
|
setTrackSpeedMaxKn('')
|
|
setTrackSpeedAvgKn('')
|
|
setUploadError(null)
|
|
} catch (err: any) {
|
|
showAlert(err.message || 'Failed to delete track')
|
|
}
|
|
}
|
|
|
|
const handleGetGps = () => {
|
|
if (readOnly) return
|
|
const lookupFallback = async () => {
|
|
const locationQuery = evLocationName.trim() || departure.trim() || destination.trim()
|
|
if (!locationQuery) {
|
|
showAlert('GPS capturing failed, and no location name is entered in "Ort / Hafen" or "Start-Hafen" to look up coordinates.')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const data = await fetchOpenWeatherCurrent(
|
|
{ q: locationQuery },
|
|
{ analyticsSource: 'entry_editor_gps_lookup' }
|
|
)
|
|
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
|
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
|
setEvGpsLat(Number(coord.lat).toFixed(6))
|
|
setEvGpsLng(Number(coord.lon).toFixed(6))
|
|
showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`)
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof WeatherApiError && e.code === 'NO_KEY') {
|
|
showAlert(t('settings.no_key'))
|
|
return
|
|
}
|
|
showAlert('Failed to retrieve GPS location or look up coordinates by location name.')
|
|
}
|
|
}
|
|
|
|
if (!navigator.geolocation) {
|
|
lookupFallback()
|
|
return
|
|
}
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
setEvGpsLat(pos.coords.latitude.toFixed(6))
|
|
setEvGpsLng(pos.coords.longitude.toFixed(6))
|
|
},
|
|
(err) => {
|
|
console.warn('GPS capturing failed, trying fallback:', err)
|
|
lookupFallback()
|
|
}
|
|
)
|
|
}
|
|
|
|
const handleFetchWeather = async () => {
|
|
const localToday = new Date()
|
|
const todayStr = `${localToday.getFullYear()}-${String(localToday.getMonth() + 1).padStart(2, '0')}-${String(localToday.getDate()).padStart(2, '0')}`
|
|
|
|
if (date && date !== todayStr) {
|
|
showAlert(t('settings.weather_date_mismatch', { date, today: todayStr }))
|
|
return
|
|
}
|
|
|
|
const hasGps = evGpsLat && evGpsLng
|
|
const fallbackLocation = evLocationName.trim() || departure.trim() || destination.trim()
|
|
|
|
if (!hasGps && !fallbackLocation) {
|
|
showAlert(t('settings.gps_error'))
|
|
return
|
|
}
|
|
|
|
setWeatherLoading(true)
|
|
try {
|
|
const data = await fetchOpenWeatherCurrent(
|
|
hasGps
|
|
? { lat: evGpsLat, lon: evGpsLng }
|
|
: { q: fallbackLocation },
|
|
{ analyticsSource: 'entry_editor' }
|
|
)
|
|
|
|
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
|
// If fetched by location, automatically pre-fill GPS coordinates
|
|
if (!hasGps && coord?.lat !== undefined && coord?.lon !== undefined) {
|
|
setEvGpsLat(Number(coord.lat).toFixed(6))
|
|
setEvGpsLng(Number(coord.lon).toFixed(6))
|
|
}
|
|
|
|
const parsed = parseOwmCurrentWeather(data)
|
|
setEvWindStrength(parsed.windStrength)
|
|
setEvWindPressure(parsed.windPressure)
|
|
if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
|
|
if (parsed.visibility) setEvVisibility(parsed.visibility)
|
|
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
|
|
|
|
showAlert(t('settings.weather_success'))
|
|
} catch (err) {
|
|
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
|
showAlert(t('settings.no_key'))
|
|
return
|
|
}
|
|
console.error('Weather prefilling failed:', err)
|
|
showAlert(t('settings.weather_error'))
|
|
} finally {
|
|
setWeatherLoading(false)
|
|
}
|
|
}
|
|
|
|
const defaultSails = i18n.language === 'de'
|
|
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
|
|
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']
|
|
|
|
const eventSailOptions = yachtSails.length > 0 ? yachtSails : defaultSails
|
|
const showSailsPickerToggle = eventSailOptions.length + 1 > 6
|
|
|
|
const toggleSailOrMotor = (item: string) => {
|
|
let currentItems = evSailsOrMotor
|
|
.split(/\s*(?:\+|\bplus\b|,)\s*/i)
|
|
.map(s => s.trim())
|
|
.filter(Boolean)
|
|
|
|
if (currentItems.some(s => s.toLowerCase() === item.toLowerCase())) {
|
|
currentItems = currentItems.filter(s => s.toLowerCase() !== item.toLowerCase())
|
|
} else {
|
|
currentItems.push(item)
|
|
}
|
|
|
|
setEvSailsOrMotor(currentItems.join(' + '))
|
|
}
|
|
|
|
const isItemActive = (item: string) => {
|
|
const currentItems = evSailsOrMotor
|
|
.split(/\s*(?:\+|\bplus\b|,)\s*/i)
|
|
.map(s => s.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
return currentItems.includes(item.toLowerCase())
|
|
}
|
|
|
|
const motorPropulsionLabel = t('logs.motor_propulsion')
|
|
const sortedEventSailOptions = [...eventSailOptions].sort((a, b) => {
|
|
const aActive = isItemActive(a)
|
|
const bActive = isItemActive(b)
|
|
if (aActive === bActive) return 0
|
|
return aActive ? -1 : 1
|
|
})
|
|
const isMotorActive = isItemActive(motorPropulsionLabel)
|
|
|
|
const clearEventForm = () => {
|
|
setEvTime(currentLocalTimeHHMM())
|
|
setEvMgk('')
|
|
setEvRwk('')
|
|
setEvWindPressure('')
|
|
setEvWindDirection('')
|
|
setEvWindStrength('')
|
|
setEvSeaState('')
|
|
setEvVisibility('')
|
|
setEvWeatherIcon('')
|
|
setEvCurrent('')
|
|
setEvHeel('')
|
|
setEvSailsOrMotor('')
|
|
setEvLogReading('')
|
|
setEvDistance('')
|
|
setEvGpsLat('')
|
|
setEvGpsLng('')
|
|
setEvRemarks('')
|
|
setEvLocationName('')
|
|
setEditingEventIndex(null)
|
|
setSailsPickerExpanded(false)
|
|
}
|
|
|
|
const fillEventForm = (ev: LogEvent) => {
|
|
const normalized = normalizeLogEvent(ev)
|
|
setEvTime(normalized.time)
|
|
setEvMgk(normalized.mgk)
|
|
setEvRwk(normalized.rwk)
|
|
setEvWindPressure(normalized.windPressure)
|
|
setEvWindDirection(normalized.windDirection)
|
|
setEvWindStrength(normalized.windStrength)
|
|
setEvSeaState(normalized.seaState)
|
|
setEvVisibility(normalized.visibility)
|
|
setEvWeatherIcon(normalized.weatherIcon)
|
|
setEvCurrent(normalized.current)
|
|
setEvHeel(normalized.heel)
|
|
setEvSailsOrMotor(normalized.sailsOrMotor)
|
|
setEvLogReading(normalized.logReading)
|
|
setEvDistance(normalized.distance)
|
|
setEvGpsLat(normalized.gpsLat)
|
|
setEvGpsLng(normalized.gpsLng)
|
|
setEvRemarks(normalized.remarks)
|
|
setEvLocationName('')
|
|
}
|
|
|
|
const resolveSignaturesAfterContentChange = (skipperOnly = false) => {
|
|
const hadSkipper = !!signSkipper
|
|
const hadCrew = !!signCrew
|
|
const cleared = hadSkipper || (hadCrew && !skipperOnly)
|
|
skipCrewSignClearRef.current = skipperOnly
|
|
const nextSkipper: SignatureValue | '' = hadSkipper ? '' : signSkipper
|
|
const nextCrew: SignatureValue | '' = hadCrew && !skipperOnly ? '' : signCrew
|
|
if (cleared) {
|
|
if (hadSkipper) setSignSkipper('')
|
|
if (hadCrew && !skipperOnly) setSignCrew('')
|
|
lockedContentHashRef.current = null
|
|
}
|
|
return { signSkipper: nextSkipper, signCrew: nextCrew, cleared }
|
|
}
|
|
|
|
const markSkipperSignatureClearedForEventChange = () => {
|
|
resolveSignaturesAfterContentChange(true)
|
|
}
|
|
|
|
const handleEditEvent = (index: number) => {
|
|
if (readOnly) return
|
|
const ev = events[index]
|
|
if (!ev) return
|
|
fillEventForm(ev)
|
|
setEditingEventIndex(index)
|
|
}
|
|
|
|
const handleCancelEventEdit = () => {
|
|
clearEventForm()
|
|
}
|
|
|
|
const handleSaveEvent = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (readOnly || !isValidTimeHHMM(evTime)) return
|
|
|
|
const eventData = buildEventFromForm()
|
|
const isEdit = editingEventIndex !== null
|
|
const hadSkipperSignature = isEdit && !!signSkipper
|
|
|
|
if (hadSkipperSignature) {
|
|
markSkipperSignatureClearedForEventChange()
|
|
}
|
|
|
|
const nextEvents = applyEventFormToEvents(eventData)
|
|
|
|
try {
|
|
await persistEntryToDb(nextEvents)
|
|
setEvents(nextEvents)
|
|
clearEventForm()
|
|
if (hadSkipperSignature) {
|
|
void showAlertRef.current(
|
|
t('logs.sign_cleared_skipper_re_sign'),
|
|
t('logs.sign_cleared_skipper_re_sign_title')
|
|
)
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Failed to auto-save event:', err)
|
|
setError(err.message || 'Failed to save event.')
|
|
}
|
|
}
|
|
|
|
const handleDeleteEvent = async (index: number) => {
|
|
if (readOnly) return
|
|
const hadSkipperSignature = !!signSkipper
|
|
markSkipperSignatureClearedForEventChange()
|
|
const nextEvents = events.filter((_, idx) => idx !== index)
|
|
setEvents(nextEvents)
|
|
if (hadSkipperSignature) {
|
|
void showAlertRef.current(
|
|
t('logs.sign_cleared_skipper_re_sign'),
|
|
t('logs.sign_cleared_skipper_re_sign_title')
|
|
)
|
|
}
|
|
if (editingEventIndex === index) {
|
|
clearEventForm()
|
|
} else if (editingEventIndex !== null && index < editingEventIndex) {
|
|
setEditingEventIndex(editingEventIndex - 1)
|
|
}
|
|
|
|
try {
|
|
await persistEntryToDb(nextEvents)
|
|
} catch (err: any) {
|
|
console.error('Failed to auto-save after event delete:', err)
|
|
setError(err.message || 'Failed to save event deletion.')
|
|
}
|
|
}
|
|
|
|
const handleDownloadPdf = async () => {
|
|
setExporting(true)
|
|
setError(null)
|
|
try {
|
|
await downloadLogbookPagePdf(logbookId, entryId, date)
|
|
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
|
} catch (err: any) {
|
|
console.error('Failed to download PDF:', err)
|
|
setError(err.message || 'Failed to generate PDF export.')
|
|
} finally {
|
|
setExporting(false)
|
|
}
|
|
}
|
|
|
|
const saveEntryChanges = useCallback(async () => {
|
|
if (readOnly) return
|
|
|
|
let eventsToSave = events
|
|
let signaturesForSave: { signSkipper: SignatureValue | ''; signCrew: SignatureValue | '' } | undefined
|
|
|
|
if (hasPendingEventForm) {
|
|
const isEdit = editingEventIndex !== null
|
|
const resolved = resolveSignaturesAfterContentChange(isEdit)
|
|
signaturesForSave = {
|
|
signSkipper: resolved.signSkipper,
|
|
signCrew: resolved.signCrew
|
|
}
|
|
if (resolved.cleared) {
|
|
void showAlertRef.current(
|
|
isEdit ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'),
|
|
isEdit ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title')
|
|
)
|
|
}
|
|
eventsToSave = applyEventFormToEvents(buildEventFromForm())
|
|
setEvents(eventsToSave)
|
|
clearEventForm()
|
|
} else if (!isDirty) {
|
|
return
|
|
}
|
|
|
|
setSaving(true)
|
|
setError(null)
|
|
|
|
try {
|
|
await persistEntryToDb({
|
|
eventsOverride: eventsToSave,
|
|
...signaturesForSave
|
|
})
|
|
|
|
await clearEntryDraft(logbookId, entryId)
|
|
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, [
|
|
readOnly, events, hasPendingEventForm, editingEventIndex, isDirty,
|
|
resolveSignaturesAfterContentChange, applyEventFormToEvents, buildEventFromForm,
|
|
clearEventForm, persistEntryToDb, logbookId, entryId, t
|
|
])
|
|
|
|
useEffect(() => {
|
|
saveBeforeLeaveRef.current = readOnly ? null : saveEntryChanges
|
|
}, [readOnly, saveEntryChanges])
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (readOnly) return
|
|
|
|
setSuccess(false)
|
|
try {
|
|
await saveEntryChanges()
|
|
setSuccess(true)
|
|
setTimeout(() => {
|
|
setSuccess(false)
|
|
onBack()
|
|
}, 1500)
|
|
} catch (err: unknown) {
|
|
console.error('Failed to save entry details:', err)
|
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="tab-placeholder">
|
|
<FileText className="header-logo spin" size={48} />
|
|
<p>{t('logs.loading')}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="crew-dashboard-layout">
|
|
{/* Top Header Controls */}
|
|
<div className="form-card" style={{ paddingBottom: '20px' }}>
|
|
<div className="section-title-bar">
|
|
<div className="section-title-left">
|
|
<button className="btn-back" onClick={() => void handleBack()} style={{ padding: '6px 12px' }}>
|
|
<ChevronLeft size={16} />
|
|
{t('logs.back_to_list')}
|
|
</button>
|
|
<div className="form-header" style={{ margin: 0 }}>
|
|
<FileText size={24} className="form-icon" />
|
|
<h2>
|
|
{t('logs.route')}: {departure || '...'} → {destination || '...'} (Tag {dayOfTravel})
|
|
</h2>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
onClick={handleDownloadPdf}
|
|
disabled={saving || exporting}
|
|
style={{ width: 'auto', padding: '8px 16px' }}
|
|
>
|
|
<Download size={16} />
|
|
<span className="hide-mobile">{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <div className="auth-error">{error}</div>}
|
|
|
|
{/* Main Journal Data Forms */}
|
|
<form onSubmit={handleSubmit} className="vessel-form">
|
|
{/* Section 1: Travel Day Headers */}
|
|
<div className="form-card">
|
|
<div className="form-header">
|
|
<FileText size={20} className="form-icon" />
|
|
<h3>{t('logs.travel_details')}</h3>
|
|
</div>
|
|
<div className="form-grid">
|
|
<div className="input-group">
|
|
<label>{t('logs.date')}</label>
|
|
<input
|
|
type="date"
|
|
className="input-text"
|
|
value={date}
|
|
onChange={(e) => setDate(e.target.value)}
|
|
disabled={saving || readOnly}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="input-group">
|
|
<label>{t('logs.day_of_travel')}</label>
|
|
<input
|
|
type="text"
|
|
className="input-text"
|
|
value={dayOfTravel}
|
|
onChange={(e) => setDayOfTravel(e.target.value)}
|
|
disabled={saving || readOnly}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="input-group">
|
|
<label>{t('logs.departure')}</label>
|
|
<input
|
|
type="text"
|
|
className="input-text"
|
|
value={departure}
|
|
onChange={(e) => setDeparture(e.target.value)}
|
|
disabled={saving || readOnly}
|
|
/>
|
|
</div>
|
|
|
|
<div className="input-group">
|
|
<label>{t('logs.destination')}</label>
|
|
<input
|
|
type="text"
|
|
className="input-text"
|
|
value={destination}
|
|
onChange={(e) => setDestination(e.target.value)}
|
|
disabled={saving || readOnly}
|
|
/>
|
|
</div>
|
|
|
|
<div className="input-group">
|
|
<label>{t('logs.motor_hours')}</label>
|
|
<input
|
|
type="number"
|
|
className="input-text"
|
|
value={motorHours}
|
|
onChange={(e) => setMotorHours(e.target.value)}
|
|
disabled={saving || readOnly}
|
|
min="0"
|
|
step="0.1"
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section 2: Freshwater and Fuel Consumption */}
|
|
<div className="form-grid">
|
|
{/* Freshwater card */}
|
|
<div className="form-card">
|
|
<div className="form-header">
|
|
<Compass size={20} className="form-icon" />
|
|
<h3>{t('logs.freshwater')}</h3>
|
|
</div>
|
|
<div className="consumption-grid">
|
|
<TankLiterInput
|
|
id="fw-morning"
|
|
label={t('logs.morning')}
|
|
value={fwMorning}
|
|
onChange={setFwMorning}
|
|
maxLiters={tankCapacities.freshwaterCapacityL}
|
|
disabled={saving || readOnly}
|
|
titleTooltip={tankCapacityTooltip}
|
|
/>
|
|
<TankLiterInput
|
|
id="fw-refilled"
|
|
label={t('logs.refilled')}
|
|
value={fwRefilled}
|
|
onChange={setFwRefilled}
|
|
maxLiters={fwRefilledMax}
|
|
disabled={saving || readOnly || fwRefilledNoCapacity}
|
|
titleTooltip={tankCapacityTooltip}
|
|
/>
|
|
<TankLiterInput
|
|
id="fw-evening"
|
|
label={t('logs.evening')}
|
|
value={fwEvening}
|
|
onChange={setFwEvening}
|
|
maxLiters={fwEveningMax}
|
|
disabled={saving || readOnly}
|
|
titleTooltip={tankCapacityTooltip}
|
|
/>
|
|
<div className="input-group">
|
|
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
|
<input
|
|
type="number"
|
|
className="input-text consumption-value"
|
|
value={fwConsumption}
|
|
readOnly
|
|
tabIndex={-1}
|
|
aria-readonly="true"
|
|
title={tankCapacityTooltip}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fuel card */}
|
|
<div className="form-card">
|
|
<div className="form-header">
|
|
<Compass size={20} className="form-icon" />
|
|
<h3>{t('logs.fuel')}</h3>
|
|
</div>
|
|
<div className="consumption-grid">
|
|
<TankLiterInput
|
|
id="fuel-morning"
|
|
label={t('logs.morning')}
|
|
value={fuelMorning}
|
|
onChange={setFuelMorning}
|
|
maxLiters={tankCapacities.fuelCapacityL}
|
|
disabled={saving || readOnly}
|
|
titleTooltip={tankCapacityTooltip}
|
|
/>
|
|
<TankLiterInput
|
|
id="fuel-refilled"
|
|
label={t('logs.refilled')}
|
|
value={fuelRefilled}
|
|
onChange={setFuelRefilled}
|
|
maxLiters={fuelRefilledMax}
|
|
disabled={saving || readOnly || fuelRefilledNoCapacity}
|
|
titleTooltip={tankCapacityTooltip}
|
|
/>
|
|
<TankLiterInput
|
|
id="fuel-evening"
|
|
label={t('logs.evening')}
|
|
value={fuelEvening}
|
|
onChange={setFuelEvening}
|
|
maxLiters={fuelEveningMax}
|
|
disabled={saving || readOnly}
|
|
titleTooltip={tankCapacityTooltip}
|
|
/>
|
|
<div className="input-group">
|
|
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
|
<input
|
|
type="number"
|
|
className="input-text consumption-value"
|
|
value={fuelConsumption}
|
|
readOnly
|
|
tabIndex={-1}
|
|
aria-readonly="true"
|
|
title={tankCapacityTooltip}
|
|
/>
|
|
</div>
|
|
|
|
<div className="input-group">
|
|
<label title={tankCapacityTooltip}>{t('logs.fuel_per_motor_hour')}</label>
|
|
<input
|
|
type="text"
|
|
className="input-text consumption-value"
|
|
value={
|
|
fuelPerMotorHour != null
|
|
? `${formatFuelPerMotorHour(fuelPerMotorHour)} L/h`
|
|
: '—'
|
|
}
|
|
readOnly
|
|
tabIndex={-1}
|
|
aria-readonly="true"
|
|
title={tankCapacityTooltip}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Greywater card */}
|
|
<div className="form-card">
|
|
<div className="form-header">
|
|
<Compass size={20} className="form-icon" />
|
|
<h3>{t('logs.greywater')}</h3>
|
|
</div>
|
|
<div className="consumption-grid">
|
|
<TankLiterInput
|
|
id="greywater-level"
|
|
label={t('logs.greywater_level')}
|
|
value={greywaterLevel}
|
|
onChange={setGreywaterLevel}
|
|
maxLiters={tankCapacities.greywaterCapacityL}
|
|
disabled={saving || readOnly}
|
|
titleTooltip={tankCapacityTooltip}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section 3: Event Journal Entries */}
|
|
<div className="form-card">
|
|
<div className="form-header mb-4">
|
|
<Compass size={20} className="form-icon" />
|
|
<h3>{t('logs.event_title')}</h3>
|
|
</div>
|
|
|
|
{/* List existing events */}
|
|
{events.length === 0 ? (
|
|
<div className="dashboard-status-msg mb-6">{t('logs.no_events')}</div>
|
|
) : (
|
|
<div className="events-scroll-container mb-6">
|
|
<table className="events-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{t('logs.event_time')}</th>
|
|
<th>{t('logs.event_mgk')}</th>
|
|
<th>{t('logs.event_rwk')}</th>
|
|
<th>{t('logs.event_wind_direction')}</th>
|
|
<th>{t('logs.event_wind_strength')}</th>
|
|
<th>{t('logs.event_sea_state')}</th>
|
|
<th>{t('logs.event_weather')}</th>
|
|
<th>{t('logs.event_log')}</th>
|
|
<th>{t('logs.event_gps')}</th>
|
|
<th>{t('logs.event_remarks')}</th>
|
|
{!readOnly && <th></th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{events.map((ev, idx) => (
|
|
<tr key={idx}>
|
|
<td className="font-mono">{ev.time}</td>
|
|
<td>{ev.mgk ? `${ev.mgk}°` : '—'}</td>
|
|
<td>{ev.rwk ? `${ev.rwk}°` : '—'}</td>
|
|
<td>{ev.windDirection || '—'}</td>
|
|
<td>{ev.windStrength || '—'}</td>
|
|
<td>{ev.seaState || '—'}</td>
|
|
<td>
|
|
{ev.weatherIcon ? (
|
|
<img
|
|
src={`https://openweathermap.org/img/wn/${ev.weatherIcon}.png`}
|
|
alt="Weather"
|
|
title="Weather Icon"
|
|
className="table-weather-img"
|
|
/>
|
|
) : (
|
|
'—'
|
|
)}
|
|
</td>
|
|
<td>{ev.logReading ? `${ev.logReading} nm` : '—'}</td>
|
|
<td className="font-mono text-sm">
|
|
{ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'}
|
|
</td>
|
|
<td className="remarks-td">{ev.remarks}</td>
|
|
{!readOnly && (
|
|
<td className="events-actions-td">
|
|
<button
|
|
type="button"
|
|
className="btn-icon"
|
|
onClick={() => handleEditEvent(idx)}
|
|
title={t('logs.edit_event')}
|
|
disabled={editingEventIndex !== null && editingEventIndex !== idx}
|
|
>
|
|
<Pencil size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-icon logout"
|
|
onClick={() => handleDeleteEvent(idx)}
|
|
title={t('logs.delete_event')}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Add New Event Form Sub-Card */}
|
|
{!readOnly && (
|
|
<div className="member-editor-card glass">
|
|
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>
|
|
{editingEventIndex !== null ? t('logs.edit_event') : t('logs.add_event')}
|
|
</h4>
|
|
|
|
<div className="form-grid mb-4">
|
|
<div className="input-group">
|
|
<label>
|
|
<Clock size={12} style={{ display: 'inline', marginRight: 4 }} />
|
|
{t('logs.event_time')} *
|
|
</label>
|
|
<EventTimeInput24h
|
|
value={evTime}
|
|
onChange={setEvTime}
|
|
disabled={saving}
|
|
aria-label={t('logs.event_time')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="input-group course-dial-section">
|
|
<label>
|
|
<Compass size={12} style={{ display: 'inline', marginRight: 4 }} />
|
|
{t('logs.event_course_section')}
|
|
</label>
|
|
<div className="course-dial-tabs" role="tablist" aria-label={t('logs.event_course_section')}>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeCourseTab === 'mgk'}
|
|
className={`course-dial-tab${activeCourseTab === 'mgk' ? ' is-active' : ''}`}
|
|
onClick={() => setActiveCourseTab('mgk')}
|
|
disabled={saving}
|
|
>
|
|
{t('logs.course_tab_mgk')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeCourseTab === 'rwk'}
|
|
className={`course-dial-tab${activeCourseTab === 'rwk' ? ' is-active' : ''}`}
|
|
onClick={() => setActiveCourseTab('rwk')}
|
|
disabled={saving}
|
|
>
|
|
{t('logs.course_tab_rwk')}
|
|
</button>
|
|
</div>
|
|
<CourseDialInput
|
|
value={activeCourseTab === 'mgk' ? evMgk : evRwk}
|
|
onChange={activeCourseTab === 'mgk' ? setEvMgk : setEvRwk}
|
|
disabled={saving}
|
|
aria-label={activeCourseTab === 'mgk' ? t('logs.event_mgk') : t('logs.event_rwk')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="input-group">
|
|
<label>{t('logs.event_log')}</label>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g. 124.5"
|
|
className="input-text"
|
|
value={evLogReading}
|
|
onChange={(e) => setEvLogReading(e.target.value)}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-grid mb-4">
|
|
<div className="input-group">
|
|
<label>{t('logs.event_location')}</label>
|
|
<input
|
|
type="text"
|
|
placeholder={t('logs.event_location_placeholder')}
|
|
className="input-text"
|
|
value={evLocationName}
|
|
onChange={(e) => setEvLocationName(e.target.value)}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
|
|
<div className="input-group">
|
|
<label>{t('logs.event_gps')} (Lat, Lng)</label>
|
|
<div className="gps-input-row">
|
|
<input
|
|
type="text"
|
|
placeholder="Lat"
|
|
className="input-text"
|
|
value={evGpsLat}
|
|
onChange={(e) => setEvGpsLat(e.target.value)}
|
|
disabled={saving}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Lng"
|
|
className="input-text"
|
|
value={evGpsLng}
|
|
onChange={(e) => setEvGpsLng(e.target.value)}
|
|
disabled={saving}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
onClick={handleGetGps}
|
|
title={t('logs.gps_btn')}
|
|
style={{ width: 'auto', padding: '12px' }}
|
|
disabled={saving}
|
|
>
|
|
<MapPin size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
onClick={handleFetchWeather}
|
|
title={t('logs.weather_btn')}
|
|
style={{ width: 'auto', padding: '12px' }}
|
|
disabled={
|
|
saving ||
|
|
weatherLoading ||
|
|
(!evGpsLat && !evLocationName.trim() && !departure.trim() && !destination.trim()) ||
|
|
(!!evGpsLat && !evGpsLng)
|
|
}
|
|
>
|
|
<CloudSun size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-grid weather-metrics-grid mb-4">
|
|
<div className="input-group course-dial-section weather-metrics-span-2">
|
|
<label>{t('logs.event_wind_direction')}</label>
|
|
<CourseDialInput
|
|
value={evWindDirection}
|
|
onChange={setEvWindDirection}
|
|
disabled={saving || weatherLoading}
|
|
allowCardinal
|
|
displayMode="auto"
|
|
aria-label={t('logs.event_wind_direction')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="input-group">
|
|
<label>{t('logs.event_wind_strength')}</label>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g. 4 Bft"
|
|
className="input-text"
|
|
value={evWindStrength}
|
|
onChange={(e) => setEvWindStrength(e.target.value)}
|
|
disabled={saving || weatherLoading}
|
|
/>
|
|
</div>
|
|
|
|
<MetricRangeInput
|
|
label={t('logs.event_wind_pressure')}
|
|
value={evWindPressure}
|
|
onChange={setEvWindPressure}
|
|
disabled={saving || weatherLoading}
|
|
min={PRESSURE_MIN_HPA}
|
|
max={PRESSURE_MAX_HPA}
|
|
step={1}
|
|
defaultNumeric={PRESSURE_DEFAULT_HPA}
|
|
parse={parsePressureHpa}
|
|
format={formatPressureHpa}
|
|
formatDisplay={(hpa) =>
|
|
t('logs.weather_slider_pressure', { value: hpa, defaultValue: `${hpa} hPa` })}
|
|
numberMin={PRESSURE_MIN_HPA}
|
|
numberMax={PRESSURE_MAX_HPA}
|
|
numberStep={1}
|
|
numberPlaceholder="1013"
|
|
/>
|
|
|
|
<MetricRangeInput
|
|
label={t('logs.event_sea_state')}
|
|
value={evSeaState}
|
|
onChange={setEvSeaState}
|
|
disabled={saving}
|
|
min={SEA_STATE_MIN}
|
|
max={SEA_STATE_MAX}
|
|
step={1}
|
|
defaultNumeric={0}
|
|
parse={parseSeaState}
|
|
format={formatSeaState}
|
|
formatDisplay={(level) =>
|
|
t('logs.weather_slider_sea_state', { value: level, defaultValue: `${level}` })}
|
|
numberMin={SEA_STATE_MIN}
|
|
numberMax={SEA_STATE_MAX}
|
|
numberStep={1}
|
|
numberPlaceholder="3"
|
|
allowLegacyText
|
|
/>
|
|
|
|
<MetricRangeInput
|
|
label={t('logs.event_visibility')}
|
|
value={evVisibility}
|
|
onChange={setEvVisibility}
|
|
disabled={saving || weatherLoading}
|
|
discreteValues={VISIBILITY_STEPS_M}
|
|
defaultNumeric={10000}
|
|
parse={parseVisibilityMeters}
|
|
format={formatVisibilityMeters}
|
|
formatDisplay={(m) => formatVisibilityMeters(m)}
|
|
hideNumberInput
|
|
/>
|
|
|
|
<MetricRangeInput
|
|
label={t('logs.event_heel')}
|
|
value={evHeel}
|
|
onChange={setEvHeel}
|
|
disabled={saving}
|
|
min={HEEL_MIN_DEG}
|
|
max={HEEL_MAX_DEG}
|
|
step={1}
|
|
defaultNumeric={0}
|
|
parse={parseHeelDeg}
|
|
format={formatHeelDeg}
|
|
formatDisplay={(deg) =>
|
|
t('logs.weather_slider_heel', { value: deg, defaultValue: `${deg}°` })}
|
|
numberMin={HEEL_MIN_DEG}
|
|
numberMax={HEEL_MAX_DEG}
|
|
numberStep={1}
|
|
numberPlaceholder="5"
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-grid mb-4">
|
|
<div className="input-group">
|
|
<label>{t('logs.event_sails')}</label>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g. Mainsail + Jib"
|
|
className="input-text"
|
|
value={evSailsOrMotor}
|
|
onChange={(e) => setEvSailsOrMotor(e.target.value)}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
|
|
<div className="input-group">
|
|
<label>{t('logs.event_distance')}</label>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g. 12 nm"
|
|
className="input-text"
|
|
value={evDistance}
|
|
onChange={(e) => setEvDistance(e.target.value)}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={[
|
|
'sails-picker-container grid-span-2',
|
|
showSailsPickerToggle ? 'is-collapsible' : '',
|
|
showSailsPickerToggle && !sailsPickerExpanded ? 'is-collapsed' : '',
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
<div className="sails-picker-pills">
|
|
{isMotorActive && (
|
|
<span
|
|
className={`sail-pill motor-pill active`}
|
|
onClick={() => toggleSailOrMotor(motorPropulsionLabel)}
|
|
>
|
|
{motorPropulsionLabel}
|
|
</span>
|
|
)}
|
|
{sortedEventSailOptions.map((sail) => (
|
|
<span
|
|
key={sail}
|
|
className={`sail-pill ${isItemActive(sail) ? 'active' : ''}`}
|
|
onClick={() => toggleSailOrMotor(sail)}
|
|
>
|
|
{sail}
|
|
</span>
|
|
))}
|
|
{!isMotorActive && (
|
|
<span
|
|
className="sail-pill motor-pill"
|
|
onClick={() => toggleSailOrMotor(motorPropulsionLabel)}
|
|
>
|
|
{motorPropulsionLabel}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{showSailsPickerToggle && (
|
|
<button
|
|
type="button"
|
|
className="sails-picker-toggle"
|
|
onClick={() => setSailsPickerExpanded((prev) => !prev)}
|
|
aria-expanded={sailsPickerExpanded}
|
|
>
|
|
{sailsPickerExpanded ? (
|
|
<>
|
|
<ChevronUp size={14} aria-hidden="true" />
|
|
{t('logs.sails_picker_show_less')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronDown size={14} aria-hidden="true" />
|
|
{t('logs.sails_picker_show_more')}
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="input-group grid-span-2">
|
|
<label>{t('logs.event_remarks')}</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Remarks"
|
|
className="input-text"
|
|
value={evRemarks}
|
|
onChange={(e) => setEvRemarks(e.target.value)}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '8px', marginLeft: 'auto', flexWrap: 'wrap' }}>
|
|
{editingEventIndex !== null && (
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
onClick={handleCancelEventEdit}
|
|
disabled={saving}
|
|
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
|
|
>
|
|
<X size={16} />
|
|
{t('logs.cancel_event_edit')}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
onClick={handleSaveEvent}
|
|
disabled={saving || !isValidTimeHHMM(evTime)}
|
|
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
|
|
>
|
|
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
|
|
{editingEventIndex !== null ? t('logs.save_event_btn') : t('logs.add_event_btn')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Track file upload */}
|
|
<div className="form-card" data-tour="entry-track">
|
|
<div className="form-header">
|
|
<Upload size={20} className="form-icon" />
|
|
<h3>{t('logs.track_upload_title')}</h3>
|
|
</div>
|
|
|
|
{uploadError && <div className="track-error-msg">{uploadError}</div>}
|
|
|
|
{!savedTrack ? (
|
|
<div
|
|
className={`track-upload-zone ${dragOver ? 'dragover' : ''}`}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
style={{ display: 'none' }}
|
|
accept=".gpx,.kml,.json,.geojson"
|
|
onChange={handleFileChange}
|
|
disabled={saving}
|
|
/>
|
|
<Upload size={36} className="track-upload-icon" />
|
|
<div className="track-upload-text">{t('logs.gps_track_upload_btn')}</div>
|
|
<div className="track-upload-subtext">{t('logs.gps_track_upload_help')}</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="track-info-header">
|
|
<div className="track-info-left">
|
|
<Upload size={16} style={{ color: '#fbbf24' }} />
|
|
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
|
|
<span className="track-info-stats">
|
|
{(savedTrack.fileType ?? 'gpx').toUpperCase()}
|
|
{savedTrack.waypoints.length > 0 && (
|
|
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
|
|
)}
|
|
{trackDistanceNm && (
|
|
<> · {trackDistanceNm} sm</>
|
|
)}
|
|
{trackSpeedMaxKn && (
|
|
<> · max {trackSpeedMaxKn} kn</>
|
|
)}
|
|
{trackSpeedAvgKn && (
|
|
<> · Ø {trackSpeedAvgKn} kn</>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="track-actions">
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
onClick={() => downloadTrackFile(savedTrack)}
|
|
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
|
|
title={t('logs.gps_tracking_btn_gpx')}
|
|
>
|
|
<Download size={14} />
|
|
<span className="hide-mobile">{t('logs.gps_tracking_btn_gpx')}</span>
|
|
</button>
|
|
{!readOnly && (
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
onClick={handleDeleteTrack}
|
|
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
|
|
title={t('logs.gps_track_delete')}
|
|
>
|
|
<Trash2 size={14} />
|
|
<span className="hide-mobile">{t('logs.gps_track_delete')}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{savedTrack.waypoints.length > 0 && (
|
|
<TrackMap waypoints={savedTrack.waypoints} />
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{!readOnly && (
|
|
<div className="nmea-import-section" style={{ marginTop: '12px' }}>
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
onClick={() => setNmeaWizardOpen(true)}
|
|
style={{ width: 'auto', padding: '8px 14px', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
|
>
|
|
<FileText size={16} />
|
|
{t('logs.nmea_import_btn')}
|
|
</button>
|
|
{nmeaArchive && (
|
|
<div className="nmea-archive-info" style={{ marginTop: '8px', display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
<span>{t('logs.nmea_archive_stored', { name: nmeaArchive.filename })}</span>
|
|
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={() => downloadNmeaArchive(nmeaArchive)}>
|
|
<Download size={14} />
|
|
</button>
|
|
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={handleDeleteNmeaArchive}>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
|
|
<div className="form-grid track-stats-grid">
|
|
<div className="input-group">
|
|
<label>{t('logs.track_distance')}</label>
|
|
<input
|
|
type="text"
|
|
inputMode="decimal"
|
|
placeholder="e.g. 5.0"
|
|
className="input-text"
|
|
value={trackDistanceNm}
|
|
onChange={(e) => setTrackDistanceNm(e.target.value)}
|
|
disabled={saving || readOnly}
|
|
/>
|
|
</div>
|
|
<div className="input-group">
|
|
<label>{t('logs.track_speed_max')}</label>
|
|
<input
|
|
type="text"
|
|
inputMode="decimal"
|
|
placeholder="e.g. 7.8"
|
|
className="input-text"
|
|
value={trackSpeedMaxKn}
|
|
onChange={(e) => setTrackSpeedMaxKn(e.target.value)}
|
|
disabled={saving || readOnly}
|
|
/>
|
|
</div>
|
|
<div className="input-group">
|
|
<label>{t('logs.track_speed_avg')}</label>
|
|
<input
|
|
type="text"
|
|
inputMode="decimal"
|
|
placeholder="e.g. 4.6"
|
|
className="input-text"
|
|
value={trackSpeedAvgKn}
|
|
onChange={(e) => setTrackSpeedAvgKn(e.target.value)}
|
|
disabled={saving || readOnly}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
|
|
|
|
<EntryCrewSection
|
|
logbookId={logbookId}
|
|
readOnly={readOnly}
|
|
value={entryCrew}
|
|
onChange={setEntryCrew}
|
|
/>
|
|
|
|
<SignatureSection
|
|
readOnly={readOnly}
|
|
disabled={saving}
|
|
isOnline={isOnline}
|
|
canSignSkipper={canSignSkipper}
|
|
canSignCrew={canSignCrew}
|
|
signSkipper={signSkipper}
|
|
signCrew={signCrew}
|
|
skipperSignatureValid={skipperSignatureValid}
|
|
crewSignatureValid={crewSignatureValid}
|
|
onSignSkipperChange={(value) => {
|
|
if (canSignSkipper && !readOnly) setSignSkipper(value)
|
|
}}
|
|
onSignCrewChange={(value) => {
|
|
if (!canSignCrew || readOnly) return
|
|
if (!value) {
|
|
setSignCrew('')
|
|
return
|
|
}
|
|
if (isPasskeySignature(value) || isClassicSignature(value)) {
|
|
setSignCrew(value)
|
|
return
|
|
}
|
|
if (!canSignSkipper) {
|
|
const userId = localStorage.getItem('active_userid') || ''
|
|
const username = localStorage.getItem('active_username') || ''
|
|
if (userId && username) {
|
|
setSignCrew(createClassicSignature({
|
|
role: 'crew',
|
|
userId,
|
|
username,
|
|
signedAt: new Date().toISOString(),
|
|
payload: value
|
|
}))
|
|
return
|
|
}
|
|
}
|
|
setSignCrew(value)
|
|
}}
|
|
onPasskeySignSkipper={handlePasskeySignSkipper}
|
|
onPasskeySignCrew={handlePasskeySignCrew}
|
|
onBeforeSign={confirmSignWarning}
|
|
/>
|
|
|
|
{/* Save Controls */}
|
|
{!readOnly && (
|
|
<div className="form-actions mt-4">
|
|
{success && (
|
|
<div className="success-toast">
|
|
<Check size={16} />
|
|
<span>{t('logs.saved')}</span>
|
|
</div>
|
|
)}
|
|
|
|
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim() || !isDirty}>
|
|
<Save size={18} />
|
|
{saving ? t('logs.saving') : t('logs.save')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</form>
|
|
|
|
<NmeaImportWizard
|
|
open={nmeaWizardOpen}
|
|
onClose={() => {
|
|
setNmeaWizardOpen(false)
|
|
void loadNmeaArchive()
|
|
}}
|
|
logbookId={logbookId}
|
|
entryId={entryId}
|
|
entryDate={date}
|
|
nmeaArchive={nmeaArchive}
|
|
onImport={handleNmeaImport}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|