feat(logbook): attribute log events to creator and show in exports
This commit is contained in:
@@ -0,0 +1,114 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface PersonSnapshot {
|
||||||
|
name: string
|
||||||
|
photo?: string | null
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatorAvatarProps {
|
||||||
|
creatorId?: string
|
||||||
|
crewSnapshotsById?: Record<string, PersonSnapshot>
|
||||||
|
fallbackName?: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#2563eb', // blue
|
||||||
|
'#059669', // emerald
|
||||||
|
'#d97706', // amber
|
||||||
|
'#dc2626', // red
|
||||||
|
'#7c3aed', // violet
|
||||||
|
'#db2777', // pink
|
||||||
|
'#0891b2', // cyan
|
||||||
|
'#4f46e5', // indigo
|
||||||
|
'#0f766e', // teal
|
||||||
|
'#9333ea', // purple
|
||||||
|
]
|
||||||
|
|
||||||
|
function getAvatarColor(name: string): string {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||||
|
}
|
||||||
|
const index = Math.abs(hash) % colors.length
|
||||||
|
return colors[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreatorAvatar({
|
||||||
|
creatorId,
|
||||||
|
crewSnapshotsById,
|
||||||
|
fallbackName,
|
||||||
|
size = 28
|
||||||
|
}: CreatorAvatarProps) {
|
||||||
|
let name = ''
|
||||||
|
let photo: string | null = null
|
||||||
|
let role = ''
|
||||||
|
|
||||||
|
if (creatorId && crewSnapshotsById && crewSnapshotsById[creatorId]) {
|
||||||
|
const snap = crewSnapshotsById[creatorId]
|
||||||
|
name = snap.name || ''
|
||||||
|
photo = snap.photo || null
|
||||||
|
role = snap.role || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to active username if owner or no crew pool matches
|
||||||
|
if (!name) {
|
||||||
|
if (creatorId === 'skipper') {
|
||||||
|
name = fallbackName || localStorage.getItem('active_username') || 'Skipper'
|
||||||
|
role = 'skipper'
|
||||||
|
} else if (fallbackName) {
|
||||||
|
name = fallbackName
|
||||||
|
} else if (creatorId) {
|
||||||
|
// If creatorId is a username itself (fallback from LiveLogView)
|
||||||
|
name = creatorId
|
||||||
|
} else {
|
||||||
|
name = '?'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = name ? name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?' : '?'
|
||||||
|
const bgColor = name === '?' ? '#64748b' : getAvatarColor(name)
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: `${Math.round(size * 0.45)}px`,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#ffffff',
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
flexShrink: 0,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleText = role ? (role === 'skipper' ? 'Skipper' : 'Crew') : ''
|
||||||
|
const tooltip = name + (roleText ? ` (${roleText})` : '')
|
||||||
|
|
||||||
|
if (photo) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={photo}
|
||||||
|
alt={name}
|
||||||
|
title={tooltip}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
objectFit: 'cover',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={style} title={tooltip} className="creator-avatar-fallback">
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,13 +23,14 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import {
|
import {
|
||||||
appendQuickEvent,
|
appendQuickEvent as apiAppendQuickEvent,
|
||||||
appendQuickEvents,
|
appendQuickEvents as apiAppendQuickEvents,
|
||||||
appendTankRefill,
|
appendTankRefill as apiAppendTankRefill,
|
||||||
findOrCreateTodayEntry,
|
findOrCreateTodayEntry,
|
||||||
loadEntry,
|
loadEntry,
|
||||||
removeLastEvent
|
removeLastEvent
|
||||||
} from '../services/quickEventLog.js'
|
} from '../services/quickEventLog.js'
|
||||||
|
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||||
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||||
import {
|
import {
|
||||||
getLastAutoPositionMs,
|
getLastAutoPositionMs,
|
||||||
@@ -160,6 +161,24 @@ function gpsFailureAlertBody(
|
|||||||
return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
|
return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findActiveCreatorId(
|
||||||
|
activeUsername: string | null,
|
||||||
|
crewSnapshotsById: Record<string, any>,
|
||||||
|
selectedSkipperId: string | null
|
||||||
|
): string {
|
||||||
|
const username = (activeUsername || '').trim()
|
||||||
|
if (username) {
|
||||||
|
const matchEntry = Object.entries(crewSnapshotsById).find(
|
||||||
|
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
|
||||||
|
)
|
||||||
|
if (matchEntry) {
|
||||||
|
return matchEntry[0]
|
||||||
|
}
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
return selectedSkipperId || 'skipper'
|
||||||
|
}
|
||||||
|
|
||||||
export default function LiveLogView({
|
export default function LiveLogView({
|
||||||
logbookId,
|
logbookId,
|
||||||
onOpenEditor,
|
onOpenEditor,
|
||||||
@@ -173,6 +192,8 @@ export default function LiveLogView({
|
|||||||
const [dayOfTravel, setDayOfTravel] = useState('')
|
const [dayOfTravel, setDayOfTravel] = useState('')
|
||||||
const [date, setDate] = useState('')
|
const [date, setDate] = useState('')
|
||||||
const [events, setEvents] = useState<LogEventPayload[]>([])
|
const [events, setEvents] = useState<LogEventPayload[]>([])
|
||||||
|
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
|
||||||
|
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
|
||||||
const [yachtSails, setYachtSails] = useState<string[]>([])
|
const [yachtSails, setYachtSails] = useState<string[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
@@ -214,6 +235,51 @@ export default function LiveLogView({
|
|||||||
dateRef.current = date
|
dateRef.current = date
|
||||||
busyRef.current = busy
|
busyRef.current = busy
|
||||||
|
|
||||||
|
const getActiveCreatorId = useCallback(() => {
|
||||||
|
const activeUsername = localStorage.getItem('active_username')
|
||||||
|
return findActiveCreatorId(activeUsername, crewSnapshotsById, selectedSkipperId)
|
||||||
|
}, [crewSnapshotsById, selectedSkipperId])
|
||||||
|
|
||||||
|
const appendQuickEvent = useCallback((
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
partialEvent: Partial<LogEventPayload>,
|
||||||
|
headerPatch?: { departure?: string; destination?: string }
|
||||||
|
) => {
|
||||||
|
return apiAppendQuickEvent(
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
{ creatorId: getActiveCreatorId(), ...partialEvent },
|
||||||
|
headerPatch
|
||||||
|
)
|
||||||
|
}, [getActiveCreatorId])
|
||||||
|
|
||||||
|
const appendQuickEvents = useCallback((
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
partialEvents: Partial<LogEventPayload>[]
|
||||||
|
) => {
|
||||||
|
const creatorId = getActiveCreatorId()
|
||||||
|
const mapped = partialEvents.map((p) => ({ creatorId, ...p }))
|
||||||
|
return apiAppendQuickEvents(logbookId, entryId, mapped)
|
||||||
|
}, [getActiveCreatorId])
|
||||||
|
|
||||||
|
const appendTankRefill = useCallback((
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
tank: 'fuel' | 'freshwater',
|
||||||
|
addLiters: number,
|
||||||
|
event: Partial<LogEventPayload>
|
||||||
|
) => {
|
||||||
|
return apiAppendTankRefill(
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
tank,
|
||||||
|
addLiters,
|
||||||
|
{ creatorId: getActiveCreatorId(), ...event }
|
||||||
|
)
|
||||||
|
}, [getActiveCreatorId])
|
||||||
|
|
||||||
const defaultSails = useMemo(
|
const defaultSails = useMemo(
|
||||||
() => (i18n.language === 'de'
|
() => (i18n.language === 'de'
|
||||||
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
|
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
|
||||||
@@ -237,6 +303,8 @@ export default function LiveLogView({
|
|||||||
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
|
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
|
||||||
setDate(String(loaded.data.date || ''))
|
setDate(String(loaded.data.date || ''))
|
||||||
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
||||||
|
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
|
||||||
|
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refreshEntry = useCallback(async (id: string) => {
|
const refreshEntry = useCallback(async (id: string) => {
|
||||||
@@ -1152,6 +1220,11 @@ export default function LiveLogView({
|
|||||||
return (
|
return (
|
||||||
<li key={`${event.time}-${index}`} className="live-log-entry">
|
<li key={`${event.time}-${index}`} className="live-log-entry">
|
||||||
<time className="live-log-time">{event.time}</time>
|
<time className="live-log-time">{event.time}</time>
|
||||||
|
<CreatorAvatar
|
||||||
|
creatorId={event.creatorId}
|
||||||
|
crewSnapshotsById={crewSnapshotsById}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
<div className="live-log-summary-block">
|
<div className="live-log-summary-block">
|
||||||
<span className="live-log-summary">{summary}</span>
|
<span className="live-log-summary">{summary}</span>
|
||||||
{voiceId && (
|
{voiceId && (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
|||||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react'
|
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react'
|
||||||
import PhotoCapture from './PhotoCapture.tsx'
|
import PhotoCapture from './PhotoCapture.tsx'
|
||||||
import EventRemarksCell from './EventRemarksCell.tsx'
|
import EventRemarksCell from './EventRemarksCell.tsx'
|
||||||
|
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||||
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
|
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
|
||||||
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
||||||
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||||
@@ -173,6 +174,24 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findActiveCreatorId(
|
||||||
|
activeUsername: string | null,
|
||||||
|
crewSnapshotsById: Record<string, any>,
|
||||||
|
selectedSkipperId: string | null
|
||||||
|
): string {
|
||||||
|
const username = (activeUsername || '').trim()
|
||||||
|
if (username) {
|
||||||
|
const matchEntry = Object.entries(crewSnapshotsById).find(
|
||||||
|
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
|
||||||
|
)
|
||||||
|
if (matchEntry) {
|
||||||
|
return matchEntry[0]
|
||||||
|
}
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
return selectedSkipperId || 'skipper'
|
||||||
|
}
|
||||||
|
|
||||||
interface LogEntryEditorProps {
|
interface LogEntryEditorProps {
|
||||||
entryId: string
|
entryId: string
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -418,8 +437,17 @@ export default function LogEntryEditor({
|
|||||||
})
|
})
|
||||||
}, [buildPayloadForSigning, signSkipper, signCrew])
|
}, [buildPayloadForSigning, signSkipper, signCrew])
|
||||||
|
|
||||||
const buildEventFromForm = (): LogEvent =>
|
const buildEventFromForm = (): LogEvent => {
|
||||||
normalizeLogEvent({
|
let creatorId: string | undefined = undefined
|
||||||
|
if (editingEventIndex !== null && events[editingEventIndex]) {
|
||||||
|
creatorId = events[editingEventIndex].creatorId
|
||||||
|
}
|
||||||
|
if (!creatorId) {
|
||||||
|
const activeUsername = localStorage.getItem('active_username')
|
||||||
|
creatorId = findActiveCreatorId(activeUsername, entryCrew.crewSnapshotsById, entryCrew.selectedSkipperId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeLogEvent({
|
||||||
time: evTime,
|
time: evTime,
|
||||||
mgk: evMgk,
|
mgk: evMgk,
|
||||||
rwk: evRwk,
|
rwk: evRwk,
|
||||||
@@ -436,8 +464,10 @@ export default function LogEntryEditor({
|
|||||||
distance: evDistance,
|
distance: evDistance,
|
||||||
gpsLat: evGpsLat,
|
gpsLat: evGpsLat,
|
||||||
gpsLng: evGpsLng,
|
gpsLng: evGpsLng,
|
||||||
remarks: evRemarks
|
remarks: evRemarks,
|
||||||
|
creatorId
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
|
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
|
||||||
if (editingEventIndex !== null) {
|
if (editingEventIndex !== null) {
|
||||||
@@ -1815,6 +1845,7 @@ export default function LogEntryEditor({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t('logs.event_time')}</th>
|
<th>{t('logs.event_time')}</th>
|
||||||
|
<th>{t('logs.event_creator')}</th>
|
||||||
<th>{t('logs.event_mgk')}</th>
|
<th>{t('logs.event_mgk')}</th>
|
||||||
<th>{t('logs.event_rwk')}</th>
|
<th>{t('logs.event_rwk')}</th>
|
||||||
<th>{t('logs.event_wind_direction')}</th>
|
<th>{t('logs.event_wind_direction')}</th>
|
||||||
@@ -1831,6 +1862,13 @@ export default function LogEntryEditor({
|
|||||||
{events.map((ev, idx) => (
|
{events.map((ev, idx) => (
|
||||||
<tr key={idx}>
|
<tr key={idx}>
|
||||||
<td className="font-mono">{ev.time}</td>
|
<td className="font-mono">{ev.time}</td>
|
||||||
|
<td style={{ textAlign: 'center', width: '40px', verticalAlign: 'middle' }}>
|
||||||
|
<CreatorAvatar
|
||||||
|
creatorId={ev.creatorId}
|
||||||
|
crewSnapshotsById={entryCrew.crewSnapshotsById}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td>{ev.mgk ? `${ev.mgk}°` : '—'}</td>
|
<td>{ev.mgk ? `${ev.mgk}°` : '—'}</td>
|
||||||
<td>{ev.rwk ? `${ev.rwk}°` : '—'}</td>
|
<td>{ev.rwk ? `${ev.rwk}°` : '—'}</td>
|
||||||
<td>{ev.windDirection || '—'}</td>
|
<td>{ev.windDirection || '—'}</td>
|
||||||
|
|||||||
@@ -344,6 +344,7 @@
|
|||||||
"carry_over_tanks_yes": "Tag over",
|
"carry_over_tanks_yes": "Tag over",
|
||||||
"carry_over_tanks_no": "Start med 0",
|
"carry_over_tanks_no": "Start med 0",
|
||||||
"event_title": "Kronologisk hændelseslog",
|
"event_title": "Kronologisk hændelseslog",
|
||||||
|
"event_creator": "Indtastet af",
|
||||||
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
|
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
|
||||||
"event_time": "Tidspunkt på dagen",
|
"event_time": "Tidspunkt på dagen",
|
||||||
"event_mgk": "MgK-kursus",
|
"event_mgk": "MgK-kursus",
|
||||||
|
|||||||
@@ -344,6 +344,7 @@
|
|||||||
"carry_over_tanks_yes": "Übernehmen",
|
"carry_over_tanks_yes": "Übernehmen",
|
||||||
"carry_over_tanks_no": "Mit 0 starten",
|
"carry_over_tanks_no": "Mit 0 starten",
|
||||||
"event_title": "Chronologisches Ereignisprotokoll",
|
"event_title": "Chronologisches Ereignisprotokoll",
|
||||||
|
"event_creator": "Eingetragen von",
|
||||||
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
||||||
"event_time": "Uhrzeit",
|
"event_time": "Uhrzeit",
|
||||||
"event_mgk": "MgK Kurs",
|
"event_mgk": "MgK Kurs",
|
||||||
|
|||||||
@@ -344,6 +344,7 @@
|
|||||||
"carry_over_tanks_yes": "Carry over",
|
"carry_over_tanks_yes": "Carry over",
|
||||||
"carry_over_tanks_no": "Start at 0",
|
"carry_over_tanks_no": "Start at 0",
|
||||||
"event_title": "Chronological Event Logbook",
|
"event_title": "Chronological Event Logbook",
|
||||||
|
"event_creator": "Entered by",
|
||||||
"no_events": "No events logged for this travel day yet.",
|
"no_events": "No events logged for this travel day yet.",
|
||||||
"event_time": "Time",
|
"event_time": "Time",
|
||||||
"event_mgk": "MgK Course",
|
"event_mgk": "MgK Course",
|
||||||
|
|||||||
@@ -344,6 +344,7 @@
|
|||||||
"carry_over_tanks_yes": "Ta over",
|
"carry_over_tanks_yes": "Ta over",
|
||||||
"carry_over_tanks_no": "Begynn med 0",
|
"carry_over_tanks_no": "Begynn med 0",
|
||||||
"event_title": "Kronologisk hendelseslogg",
|
"event_title": "Kronologisk hendelseslogg",
|
||||||
|
"event_creator": "Registrert av",
|
||||||
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
|
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
|
||||||
"event_time": "Tid på døgnet",
|
"event_time": "Tid på døgnet",
|
||||||
"event_mgk": "MgK-kurs",
|
"event_mgk": "MgK-kurs",
|
||||||
|
|||||||
@@ -344,6 +344,7 @@
|
|||||||
"carry_over_tanks_yes": "Ta över",
|
"carry_over_tanks_yes": "Ta över",
|
||||||
"carry_over_tanks_no": "Börja med 0",
|
"carry_over_tanks_no": "Börja med 0",
|
||||||
"event_title": "Kronologisk händelselogg",
|
"event_title": "Kronologisk händelselogg",
|
||||||
|
"event_creator": "Registrerad av",
|
||||||
"no_events": "Inga händelser inlagda för denna resdag ännu.",
|
"no_events": "Inga händelser inlagda för denna resdag ännu.",
|
||||||
"event_time": "Tid på dygnet",
|
"event_time": "Tid på dygnet",
|
||||||
"event_mgk": "MgK-kurs",
|
"event_mgk": "MgK-kurs",
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
|
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
|
||||||
'Skipper Signature', 'Crew Signature',
|
'Skipper Signature', 'Crew Signature',
|
||||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
||||||
'Event Time', 'MgK Course', 'RwK Course',
|
'Event Time', 'Event Creator', 'MgK Course', 'RwK Course',
|
||||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
|
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
|
||||||
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||||
'Latitude', 'Longitude', 'Remarks',
|
'Latitude', 'Longitude', 'Remarks',
|
||||||
@@ -122,6 +122,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
const greywaterLevel = entry.greywater?.level ?? '';
|
const greywaterLevel = entry.greywater?.level ?? '';
|
||||||
const aiSummary = entry.aiSummary ?? '';
|
const aiSummary = entry.aiSummary ?? '';
|
||||||
|
|
||||||
|
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
|
||||||
const eventsList = entry.events || [];
|
const eventsList = entry.events || [];
|
||||||
if (eventsList.length === 0) {
|
if (eventsList.length === 0) {
|
||||||
// Create one row even if there are no events for the day
|
// Create one row even if there are no events for the day
|
||||||
@@ -129,7 +130,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
dateVal, travelDay, dep, dest, aiSummary,
|
dateVal, travelDay, dep, dest, aiSummary,
|
||||||
signS, signC,
|
signS, signC,
|
||||||
trackDist, trackMax, trackAvg, motorH,
|
trackDist, trackMax, trackAvg, motorH,
|
||||||
'', '', '',
|
'', '', '', '',
|
||||||
'', '', '', '', '',
|
'', '', '', '', '',
|
||||||
'', '', '', '', '',
|
'', '', '', '', '',
|
||||||
'', '', '',
|
'', '', '',
|
||||||
@@ -142,11 +143,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
// Sort events chronologically by time
|
// Sort events chronologically by time
|
||||||
const sortedEvents = sortLogEventsByTime(eventsList);
|
const sortedEvents = sortLogEventsByTime(eventsList);
|
||||||
for (const ev of sortedEvents) {
|
for (const ev of sortedEvents) {
|
||||||
|
const creatorSnap = ev.creatorId ? crewSnapshots[ev.creatorId] : null;
|
||||||
|
let creatorName = '';
|
||||||
|
if (creatorSnap) {
|
||||||
|
creatorName = creatorSnap.name || '';
|
||||||
|
} else if (ev.creatorId === 'skipper') {
|
||||||
|
creatorName = 'Skipper';
|
||||||
|
} else if (ev.creatorId) {
|
||||||
|
creatorName = ev.creatorId;
|
||||||
|
}
|
||||||
|
|
||||||
rows.push([
|
rows.push([
|
||||||
dateVal, travelDay, dep, dest, aiSummary,
|
dateVal, travelDay, dep, dest, aiSummary,
|
||||||
signS, signC,
|
signS, signC,
|
||||||
trackDist, trackMax, trackAvg, motorH,
|
trackDist, trackMax, trackAvg, motorH,
|
||||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
ev.time || '', creatorName, ev.mgk || '', ev.rwk || '',
|
||||||
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
||||||
ev.visibility || '',
|
ev.visibility || '',
|
||||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ function formatPasskeySignDate(signedAt: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 = '', owner = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||||
let entry: any = null;
|
let entry: any = null;
|
||||||
|
|
||||||
if (preloadedData) {
|
if (preloadedData) {
|
||||||
const yacht = preloadedData.yacht || {};
|
const yacht = preloadedData.yacht || {};
|
||||||
yachtName = yacht.name || '';
|
yachtName = yacht.name || '';
|
||||||
|
owner = yacht.owner || '';
|
||||||
homePort = yacht.port || '';
|
homePort = yacht.port || '';
|
||||||
registration = yacht.registrationNumber || yacht.registration || '';
|
registration = yacht.registrationNumber || yacht.registration || '';
|
||||||
callsign = yacht.callSign || '';
|
callsign = yacht.callSign || '';
|
||||||
@@ -35,6 +36,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
const yacht = await resolveVesselForLogbook(logbookId)
|
const yacht = await resolveVesselForLogbook(logbookId)
|
||||||
if (yacht) {
|
if (yacht) {
|
||||||
yachtName = yacht.name || ''
|
yachtName = yacht.name || ''
|
||||||
|
owner = yacht.owner || ''
|
||||||
homePort = yacht.homePort || ''
|
homePort = yacht.homePort || ''
|
||||||
registration = yacht.registrationNumber || ''
|
registration = yacht.registrationNumber || ''
|
||||||
callsign = yacht.callSign || ''
|
callsign = yacht.callSign || ''
|
||||||
@@ -74,24 +76,56 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
doc.setFontSize(8.5);
|
doc.setFontSize(8.5);
|
||||||
doc.setFont('Helvetica', 'normal');
|
doc.setFont('Helvetica', 'normal');
|
||||||
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
|
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
|
||||||
doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21);
|
doc.text(`Eigner: ${owner || '—'}`, 55, 21);
|
||||||
doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21);
|
doc.text(`Heimathafen: ${homePort || '—'}`, 100, 21);
|
||||||
doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21);
|
doc.text(`Kennzeichen: ${registration || '—'}`, 145, 21);
|
||||||
doc.text(`ATIS: ${atis || '—'}`, 210, 21);
|
doc.text(`Rufzeichen: ${callsign || '—'}`, 190, 21);
|
||||||
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21);
|
doc.text(`ATIS: ${atis || '—'}`, 230, 21);
|
||||||
|
doc.text(`MMSI: ${mmsi || '—'}`, 260, 21);
|
||||||
|
|
||||||
doc.text(`Datum: ${entry.date || '—'}`, 10, 23);
|
doc.text(`Datum: ${entry.date || '—'}`, 10, 24);
|
||||||
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23);
|
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 24);
|
||||||
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23);
|
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 24);
|
||||||
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23);
|
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 24);
|
||||||
|
|
||||||
|
// Format Crew names with initials
|
||||||
|
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {}
|
||||||
|
const crewList: string[] = []
|
||||||
|
|
||||||
|
if (entry.selectedSkipperId && crewSnapshots[entry.selectedSkipperId]) {
|
||||||
|
const name = crewSnapshots[entry.selectedSkipperId].name || 'Skipper'
|
||||||
|
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || 'S'
|
||||||
|
crewList.push(`${name} [${initial}] (Skipper)`)
|
||||||
|
} else if (crewSnapshots['skipper']) {
|
||||||
|
const name = crewSnapshots['skipper'].name || 'Skipper'
|
||||||
|
crewList.push(`${name} [S] (Skipper)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(entry.selectedCrewIds)) {
|
||||||
|
for (const crewId of entry.selectedCrewIds) {
|
||||||
|
const snap = crewSnapshots[crewId]
|
||||||
|
if (snap) {
|
||||||
|
const name = snap.name || ''
|
||||||
|
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?'
|
||||||
|
crewList.push(`${name} [${initial}]`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crewText = crewList.length > 0 ? `Besatzung (Crew): ${crewList.join(', ')}` : ''
|
||||||
|
|
||||||
|
doc.setFont('Helvetica', 'normal');
|
||||||
if (entry.trackDistanceNm) {
|
if (entry.trackDistanceNm) {
|
||||||
doc.setFont('Helvetica', 'normal');
|
|
||||||
doc.text(
|
doc.text(
|
||||||
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
|
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
|
||||||
10,
|
10,
|
||||||
27
|
27
|
||||||
);
|
);
|
||||||
|
if (crewText) {
|
||||||
|
doc.text(crewText, 140, 27);
|
||||||
|
}
|
||||||
|
} else if (crewText) {
|
||||||
|
doc.text(crewText, 10, 27);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Divider line
|
// Divider line
|
||||||
@@ -175,8 +209,28 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
doc.text(gps, writeX + 1, y + 4.2);
|
doc.text(gps, writeX + 1, y + 4.2);
|
||||||
writeX += colWidths[11];
|
writeX += colWidths[11];
|
||||||
|
|
||||||
|
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
|
||||||
|
let initial = '';
|
||||||
|
if (ev.creatorId) {
|
||||||
|
const snap = crewSnapshots[ev.creatorId];
|
||||||
|
let name = '';
|
||||||
|
if (snap) {
|
||||||
|
name = snap.name || '';
|
||||||
|
} else if (ev.creatorId === 'skipper') {
|
||||||
|
name = 'Skipper';
|
||||||
|
} else {
|
||||||
|
name = ev.creatorId;
|
||||||
|
}
|
||||||
|
if (name) {
|
||||||
|
initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clip remarks to fit within the 94mm bounds
|
// Clip remarks to fit within the 94mm bounds
|
||||||
const remarks = ev.remarks || '';
|
let remarks = ev.remarks || '';
|
||||||
|
if (initial) {
|
||||||
|
remarks = `[${initial}] ${remarks}`;
|
||||||
|
}
|
||||||
const maxChars = 65;
|
const maxChars = 65;
|
||||||
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
|
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
|
||||||
doc.text(clippedRemarks, writeX + 1, y + 4.2);
|
doc.text(clippedRemarks, writeX + 1, y + 4.2);
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ function buildEncryptedPayload(
|
|||||||
consumption: fuel.consumption ?? 0
|
consumption: fuel.consumption ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entryCrew = data.selectedSkipperId
|
||||||
|
? {
|
||||||
|
selectedSkipperId: String(data.selectedSkipperId),
|
||||||
|
selectedCrewIds: Array.isArray(data.selectedCrewIds) ? data.selectedCrewIds.map(String) : [],
|
||||||
|
crewSnapshotsById: (data.crewSnapshotsById as Record<string, any>) || {}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
const payload = buildLogEntryPayload({
|
const payload = buildLogEntryPayload({
|
||||||
date: String(data.date || ''),
|
date: String(data.date || ''),
|
||||||
dayOfTravel: String(data.dayOfTravel || ''),
|
dayOfTravel: String(data.dayOfTravel || ''),
|
||||||
@@ -121,7 +129,8 @@ function buildEncryptedPayload(
|
|||||||
motorHoursRaw != null && motorHoursRaw !== ''
|
motorHoursRaw != null && motorHoursRaw !== ''
|
||||||
? parseFloat(String(motorHoursRaw))
|
? parseFloat(String(motorHoursRaw))
|
||||||
: undefined,
|
: undefined,
|
||||||
events: options.events
|
events: options.events,
|
||||||
|
entryCrew
|
||||||
})
|
})
|
||||||
|
|
||||||
const clear = options.clearSignatures
|
const clear = options.clearSignatures
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface LogEventPayload {
|
|||||||
gpsLat: string
|
gpsLat: string
|
||||||
gpsLng: string
|
gpsLng: string
|
||||||
remarks: string
|
remarks: string
|
||||||
|
creatorId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calendar date YYYY-MM-DD in local timezone (matches logbook entry `date` field). */
|
/** Calendar date YYYY-MM-DD in local timezone (matches logbook entry `date` field). */
|
||||||
@@ -85,7 +86,7 @@ export function joinTimeHHMM(hours: string, minutes: string): string {
|
|||||||
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
|
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
|
||||||
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
||||||
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
||||||
'gpsLat', 'gpsLng', 'remarks'
|
'gpsLat', 'gpsLng', 'remarks', 'creatorId'
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
|
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
|
||||||
@@ -109,10 +110,11 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
|
|||||||
distance: '',
|
distance: '',
|
||||||
gpsLat: '',
|
gpsLat: '',
|
||||||
gpsLng: '',
|
gpsLng: '',
|
||||||
remarks: ''
|
remarks: '',
|
||||||
|
creatorId: e.creatorId ? String(e.creatorId).trim() : undefined
|
||||||
}
|
}
|
||||||
for (const key of LOG_EVENT_FIELDS) {
|
for (const key of LOG_EVENT_FIELDS) {
|
||||||
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection') continue
|
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection' || key === 'creatorId') continue
|
||||||
normalized[key] = String(e[key] ?? '').trim()
|
normalized[key] = String(e[key] ?? '').trim()
|
||||||
}
|
}
|
||||||
return normalized
|
return normalized
|
||||||
@@ -122,7 +124,7 @@ export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean
|
|||||||
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time')
|
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time' && key !== 'creatorId')
|
||||||
|
|
||||||
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */
|
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */
|
||||||
export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
|
export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user