fix(logs): Ereignis-Bearbeitung sichern und Warnung bei ungespeicherten Änderungen
Normalisiert partielle Logbuch-Events beim Speichern (z. B. Besegelung) und warnt beim Verlassen von Editor, Tabs und Browser. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+25
-13
@@ -13,6 +13,7 @@ import SettingsForm from './components/SettingsForm.tsx'
|
|||||||
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
||||||
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
||||||
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
|
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
|
||||||
|
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
|
||||||
import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
|
import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +49,7 @@ const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
|
const { confirmLeave } = useUnsavedChangesContext()
|
||||||
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
||||||
@@ -347,7 +349,14 @@ function App() {
|
|||||||
consumePendingPushLogbook()
|
consumePendingPushLogbook()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleTabChange = async (tab: AppTab) => {
|
||||||
|
if (tab === activeTab) return
|
||||||
|
if (!(await confirmLeave())) return
|
||||||
|
setActiveTab(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
if (!(await confirmLeave())) return
|
||||||
void logoutUser()
|
void logoutUser()
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
setActiveLogbookId(null)
|
setActiveLogbookId(null)
|
||||||
@@ -358,7 +367,8 @@ function App() {
|
|||||||
localStorage.removeItem('active_logbook_title')
|
localStorage.removeItem('active_logbook_title')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBackToDashboard = () => {
|
const handleBackToDashboard = async () => {
|
||||||
|
if (!(await confirmLeave())) return
|
||||||
setActiveLogbookId(null)
|
setActiveLogbookId(null)
|
||||||
setActiveLogbookTitle(null)
|
setActiveLogbookTitle(null)
|
||||||
setTourSelectedEntryId(null)
|
setTourSelectedEntryId(null)
|
||||||
@@ -505,7 +515,7 @@ function App() {
|
|||||||
<aside className="app-sidebar">
|
<aside className="app-sidebar">
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('logs')}
|
onClick={() => void handleTabChange('logs')}
|
||||||
data-tour="nav-logs"
|
data-tour="nav-logs"
|
||||||
>
|
>
|
||||||
<FileText size={18} />
|
<FileText size={18} />
|
||||||
@@ -514,7 +524,7 @@ function App() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('vessel')}
|
onClick={() => void handleTabChange('vessel')}
|
||||||
data-tour="nav-vessel"
|
data-tour="nav-vessel"
|
||||||
>
|
>
|
||||||
<Ship size={18} />
|
<Ship size={18} />
|
||||||
@@ -523,7 +533,7 @@ function App() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('crew')}
|
onClick={() => void handleTabChange('crew')}
|
||||||
data-tour="nav-crew"
|
data-tour="nav-crew"
|
||||||
>
|
>
|
||||||
<Users size={18} />
|
<Users size={18} />
|
||||||
@@ -542,7 +552,7 @@ function App() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('stats')}
|
onClick={() => void handleTabChange('stats')}
|
||||||
data-tour="nav-stats"
|
data-tour="nav-stats"
|
||||||
>
|
>
|
||||||
<BarChart2 size={18} />
|
<BarChart2 size={18} />
|
||||||
@@ -551,7 +561,7 @@ function App() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('settings')}
|
onClick={() => void handleTabChange('settings')}
|
||||||
>
|
>
|
||||||
<Settings size={18} />
|
<Settings size={18} />
|
||||||
{t('nav.settings')}
|
{t('nav.settings')}
|
||||||
@@ -604,12 +614,14 @@ function App() {
|
|||||||
export default function AppWrapper() {
|
export default function AppWrapper() {
|
||||||
return (
|
return (
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<AppTourProvider>
|
<UnsavedChangesProvider>
|
||||||
<PwaUpdatePrompt />
|
<AppTourProvider>
|
||||||
<App />
|
<PwaUpdatePrompt />
|
||||||
<AppTourOverlay />
|
<App />
|
||||||
</AppTourProvider>
|
<AppTourOverlay />
|
||||||
<AppFooter />
|
</AppTourProvider>
|
||||||
|
<AppFooter />
|
||||||
|
</UnsavedChangesProvider>
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
hasAnySignature
|
hasAnySignature
|
||||||
} from '../utils/signatures.js'
|
} from '../utils/signatures.js'
|
||||||
import type { SignatureValue } from '../types/signatures.js'
|
import type { SignatureValue } from '../types/signatures.js'
|
||||||
import { buildLogEntryPayload, sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||||
import { signLogEntry } from '../services/entrySigning.js'
|
import { signLogEntry } from '../services/entrySigning.js'
|
||||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
type SavedTrack
|
type SavedTrack
|
||||||
} from '../services/trackUpload.js'
|
} from '../services/trackUpload.js'
|
||||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||||
|
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||||
|
|
||||||
function emptyTankLevels() {
|
function emptyTankLevels() {
|
||||||
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
||||||
@@ -248,7 +249,60 @@ export default function LogEntryEditor({
|
|||||||
})
|
})
|
||||||
}, [buildPayloadForSigning, signSkipper, signCrew])
|
}, [buildPayloadForSigning, signSkipper, signCrew])
|
||||||
|
|
||||||
const isDirty = savedFingerprint !== null && currentFingerprint !== savedFingerprint
|
const buildEventFromForm = (): LogEvent =>
|
||||||
|
normalizeLogEvent({
|
||||||
|
time: evTime,
|
||||||
|
mgk: evMgk,
|
||||||
|
rwk: evRwk,
|
||||||
|
windPressure: evWindPressure,
|
||||||
|
windDirection: evWindDirection,
|
||||||
|
windStrength: evWindStrength,
|
||||||
|
seaState: evSeaState,
|
||||||
|
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(() => {
|
||||||
|
if (!evTime.trim()) return false
|
||||||
|
const draft = buildEventFromForm()
|
||||||
|
if (editingEventIndex !== null) {
|
||||||
|
const original = events[editingEventIndex]
|
||||||
|
return original ? !logEventsEqual(draft, original) : false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, [
|
||||||
|
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
|
||||||
|
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
||||||
|
evGpsLat, evGpsLng, evRemarks, editingEventIndex, events
|
||||||
|
])
|
||||||
|
|
||||||
|
const isDirty = savedFingerprint !== null && (
|
||||||
|
currentFingerprint !== savedFingerprint || hasPendingEventForm
|
||||||
|
)
|
||||||
|
|
||||||
|
const { confirmLeave } = useRegisterUnsavedChanges(
|
||||||
|
`log-entry-${entryId}`,
|
||||||
|
!readOnly && !loading && isDirty
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleBack = async () => {
|
||||||
|
if (!(await confirmLeave())) return
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
|
||||||
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
|
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
|
||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
@@ -483,7 +537,7 @@ export default function LogEntryEditor({
|
|||||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||||
loadTrackStatsFromEntry(preloadedEntry)
|
loadTrackStatsFromEntry(preloadedEntry)
|
||||||
setEvents(sortLogEventsByTime(preloadedEntry.events || []))
|
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
||||||
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -516,7 +570,7 @@ export default function LogEntryEditor({
|
|||||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||||
loadTrackStatsFromEntry(decrypted)
|
loadTrackStatsFromEntry(decrypted)
|
||||||
setEvents(sortLogEventsByTime(decrypted.events || []))
|
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
||||||
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -783,25 +837,6 @@ export default function LogEntryEditor({
|
|||||||
return currentItems.includes(item.toLowerCase())
|
return currentItems.includes(item.toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildEventFromForm = (): LogEvent => ({
|
|
||||||
time: evTime,
|
|
||||||
mgk: evMgk.trim(),
|
|
||||||
rwk: evRwk.trim(),
|
|
||||||
windPressure: evWindPressure.trim(),
|
|
||||||
windDirection: evWindDirection.trim(),
|
|
||||||
windStrength: evWindStrength.trim(),
|
|
||||||
seaState: evSeaState.trim(),
|
|
||||||
weatherIcon: evWeatherIcon.trim(),
|
|
||||||
current: evCurrent.trim(),
|
|
||||||
heel: evHeel.trim(),
|
|
||||||
sailsOrMotor: evSailsOrMotor.trim(),
|
|
||||||
logReading: evLogReading.trim(),
|
|
||||||
distance: evDistance.trim(),
|
|
||||||
gpsLat: evGpsLat.trim(),
|
|
||||||
gpsLng: evGpsLng.trim(),
|
|
||||||
remarks: evRemarks.trim()
|
|
||||||
})
|
|
||||||
|
|
||||||
const clearEventForm = () => {
|
const clearEventForm = () => {
|
||||||
setEvTime('')
|
setEvTime('')
|
||||||
setEvMgk('')
|
setEvMgk('')
|
||||||
@@ -824,22 +859,23 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fillEventForm = (ev: LogEvent) => {
|
const fillEventForm = (ev: LogEvent) => {
|
||||||
setEvTime(ev.time)
|
const normalized = normalizeLogEvent(ev)
|
||||||
setEvMgk(ev.mgk)
|
setEvTime(normalized.time)
|
||||||
setEvRwk(ev.rwk)
|
setEvMgk(normalized.mgk)
|
||||||
setEvWindPressure(ev.windPressure)
|
setEvRwk(normalized.rwk)
|
||||||
setEvWindDirection(ev.windDirection)
|
setEvWindPressure(normalized.windPressure)
|
||||||
setEvWindStrength(ev.windStrength)
|
setEvWindDirection(normalized.windDirection)
|
||||||
setEvSeaState(ev.seaState)
|
setEvWindStrength(normalized.windStrength)
|
||||||
setEvWeatherIcon(ev.weatherIcon)
|
setEvSeaState(normalized.seaState)
|
||||||
setEvCurrent(ev.current)
|
setEvWeatherIcon(normalized.weatherIcon)
|
||||||
setEvHeel(ev.heel)
|
setEvCurrent(normalized.current)
|
||||||
setEvSailsOrMotor(ev.sailsOrMotor)
|
setEvHeel(normalized.heel)
|
||||||
setEvLogReading(ev.logReading)
|
setEvSailsOrMotor(normalized.sailsOrMotor)
|
||||||
setEvDistance(ev.distance)
|
setEvLogReading(normalized.logReading)
|
||||||
setEvGpsLat(ev.gpsLat)
|
setEvDistance(normalized.distance)
|
||||||
setEvGpsLng(ev.gpsLng)
|
setEvGpsLat(normalized.gpsLat)
|
||||||
setEvRemarks(ev.remarks)
|
setEvGpsLng(normalized.gpsLng)
|
||||||
|
setEvRemarks(normalized.remarks)
|
||||||
setEvLocationName('')
|
setEvLocationName('')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,27 +902,25 @@ export default function LogEntryEditor({
|
|||||||
if (readOnly || !evTime) return
|
if (readOnly || !evTime) return
|
||||||
|
|
||||||
const eventData = buildEventFromForm()
|
const eventData = buildEventFromForm()
|
||||||
let nextEvents: LogEvent[]
|
const isEdit = editingEventIndex !== null
|
||||||
|
const hadSkipperSignature = isEdit && !!signSkipper
|
||||||
|
|
||||||
if (editingEventIndex !== null) {
|
if (hadSkipperSignature) {
|
||||||
const hadSkipperSignature = !!signSkipper
|
|
||||||
markSkipperSignatureClearedForEventChange()
|
markSkipperSignatureClearedForEventChange()
|
||||||
nextEvents = sortLogEventsByTime(events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev)))
|
}
|
||||||
|
|
||||||
|
const nextEvents = applyEventFormToEvents(eventData)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await persistEntryToDb(nextEvents)
|
||||||
|
setEvents(nextEvents)
|
||||||
|
clearEventForm()
|
||||||
if (hadSkipperSignature) {
|
if (hadSkipperSignature) {
|
||||||
void showAlertRef.current(
|
void showAlertRef.current(
|
||||||
t('logs.sign_cleared_skipper_re_sign'),
|
t('logs.sign_cleared_skipper_re_sign'),
|
||||||
t('logs.sign_cleared_skipper_re_sign_title')
|
t('logs.sign_cleared_skipper_re_sign_title')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
nextEvents = sortLogEventsByTime([...events, eventData])
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents(nextEvents)
|
|
||||||
clearEventForm()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await persistEntryToDb(nextEvents)
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to auto-save event:', err)
|
console.error('Failed to auto-save event:', err)
|
||||||
setError(err.message || 'Failed to save event.')
|
setError(err.message || 'Failed to save event.')
|
||||||
@@ -935,13 +969,28 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (readOnly || !isDirty) return
|
if (readOnly) return
|
||||||
|
|
||||||
|
let eventsToSave = events
|
||||||
|
|
||||||
|
if (hasPendingEventForm) {
|
||||||
|
const isEdit = editingEventIndex !== null
|
||||||
|
if (isEdit && signSkipper) {
|
||||||
|
markSkipperSignatureClearedForEventChange()
|
||||||
|
}
|
||||||
|
eventsToSave = applyEventFormToEvents(buildEventFromForm())
|
||||||
|
setEvents(eventsToSave)
|
||||||
|
clearEventForm()
|
||||||
|
} else if (!isDirty) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await persistEntryToDb()
|
await persistEntryToDb(eventsToSave)
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||||
@@ -972,7 +1021,7 @@ export default function LogEntryEditor({
|
|||||||
<div className="form-card" style={{ paddingBottom: '20px' }}>
|
<div className="form-card" style={{ paddingBottom: '20px' }}>
|
||||||
<div className="section-title-bar">
|
<div className="section-title-bar">
|
||||||
<div className="section-title-left">
|
<div className="section-title-left">
|
||||||
<button className="btn-back" onClick={onBack} style={{ padding: '6px 12px' }}>
|
<button className="btn-back" onClick={() => void handleBack()} style={{ padding: '6px 12px' }}>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
{t('logs.back_to_list')}
|
{t('logs.back_to_list')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useMemo,
|
||||||
|
type ReactNode
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useDialog } from '../components/ModalDialog.tsx'
|
||||||
|
|
||||||
|
interface UnsavedChangesContextValue {
|
||||||
|
setDirty: (source: string, dirty: boolean) => void
|
||||||
|
confirmLeave: () => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null)
|
||||||
|
|
||||||
|
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { showConfirm } = useDialog()
|
||||||
|
const dirtySources = useRef(new Set<string>())
|
||||||
|
|
||||||
|
const setDirty = useCallback((source: string, dirty: boolean) => {
|
||||||
|
if (dirty) dirtySources.current.add(source)
|
||||||
|
else dirtySources.current.delete(source)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const confirmLeave = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (dirtySources.current.size === 0) return true
|
||||||
|
return showConfirm(
|
||||||
|
t('common.unsaved_changes_message'),
|
||||||
|
t('common.unsaved_changes_title'),
|
||||||
|
t('common.unsaved_changes_leave'),
|
||||||
|
t('common.unsaved_changes_stay')
|
||||||
|
)
|
||||||
|
}, [showConfirm, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
|
if (dirtySources.current.size === 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.returnValue = ''
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', handler)
|
||||||
|
return () => window.removeEventListener('beforeunload', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnsavedChangesContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</UnsavedChangesContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnsavedChangesContext(): UnsavedChangesContextValue {
|
||||||
|
const ctx = useContext(UnsavedChangesContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useUnsavedChangesContext must be used within UnsavedChangesProvider')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
|
||||||
|
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
|
||||||
|
const { setDirty, confirmLeave } = useUnsavedChangesContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDirty(source, isDirty)
|
||||||
|
return () => setDirty(source, false)
|
||||||
|
}, [source, isDirty, setDirty])
|
||||||
|
|
||||||
|
return { confirmLeave }
|
||||||
|
}
|
||||||
@@ -6,6 +6,12 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
||||||
|
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
||||||
|
"unsaved_changes_leave": "Verlassen",
|
||||||
|
"unsaved_changes_stay": "Bleiben"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"vessel": "Schiffsdaten",
|
"vessel": "Schiffsdaten",
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Beta release — features may still change"
|
"beta_hint": "Beta release — features may still change"
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"unsaved_changes_title": "Unsaved changes",
|
||||||
|
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
||||||
|
"unsaved_changes_leave": "Leave",
|
||||||
|
"unsaved_changes_stay": "Stay"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"vessel": "Vessel Profile",
|
"vessel": "Vessel Profile",
|
||||||
|
|||||||
@@ -17,8 +17,47 @@ export interface LogEventPayload {
|
|||||||
remarks: string
|
remarks: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
|
||||||
|
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
||||||
|
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
||||||
|
'gpsLat', 'gpsLng', 'remarks'
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
|
||||||
|
export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<string, unknown>): LogEventPayload {
|
||||||
|
const e = event as Record<string, unknown>
|
||||||
|
const timeRaw = String(e.time ?? '').trim()
|
||||||
|
const normalized: LogEventPayload = {
|
||||||
|
time: timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw,
|
||||||
|
mgk: '',
|
||||||
|
rwk: '',
|
||||||
|
windPressure: '',
|
||||||
|
windDirection: '',
|
||||||
|
windStrength: '',
|
||||||
|
seaState: '',
|
||||||
|
weatherIcon: '',
|
||||||
|
current: '',
|
||||||
|
heel: '',
|
||||||
|
sailsOrMotor: '',
|
||||||
|
logReading: '',
|
||||||
|
distance: '',
|
||||||
|
gpsLat: '',
|
||||||
|
gpsLng: '',
|
||||||
|
remarks: ''
|
||||||
|
}
|
||||||
|
for (const key of LOG_EVENT_FIELDS) {
|
||||||
|
if (key === 'time') continue
|
||||||
|
normalized[key] = String(e[key] ?? '').trim()
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean {
|
||||||
|
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
||||||
|
}
|
||||||
|
|
||||||
/** Chronological order: earliest time first (HH:MM). */
|
/** Chronological order: earliest time first (HH:MM). */
|
||||||
export function sortLogEventsByTime<T extends Pick<LogEventPayload, 'time'>>(events: T[]): T[] {
|
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
|
||||||
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +82,7 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
|
|||||||
destination: input.destination.trim(),
|
destination: input.destination.trim(),
|
||||||
freshwater: { ...input.freshwater },
|
freshwater: { ...input.freshwater },
|
||||||
fuel: { ...input.fuel },
|
fuel: { ...input.fuel },
|
||||||
events: sortLogEventsByTime(input.events.map((e) => ({ ...e })))
|
events: sortLogEventsByTime(input.events.map((e) => normalizeLogEvent(e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
|
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
|
||||||
|
|||||||
Reference in New Issue
Block a user