feat: logbook filter by crew/vessel and save-on-leave dialog
Extend dashboard search with ship name and crew name parts from local data. When leaving a dirty travel day, offer save, discard, or stay instead of only leave/cancel. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -412,9 +412,15 @@ export default function LogEntryEditor({
|
||||
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
|
||||
!readOnly && !loading && isDirty,
|
||||
invokeSaveBeforeLeave
|
||||
)
|
||||
|
||||
const handleBack = async () => {
|
||||
@@ -1207,8 +1213,7 @@ export default function LogEntryEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const saveEntryChanges = useCallback(async () => {
|
||||
if (readOnly) return
|
||||
|
||||
let eventsToSave = events
|
||||
@@ -1236,7 +1241,6 @@ export default function LogEntryEditor({
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
await persistEntryToDb({
|
||||
@@ -1245,9 +1249,28 @@ export default function LogEntryEditor({
|
||||
})
|
||||
|
||||
await clearEntryDraft(logbookId, entryId)
|
||||
|
||||
setSuccess(true)
|
||||
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()
|
||||
@@ -1255,8 +1278,6 @@ export default function LogEntryEditor({
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save entry details:', err)
|
||||
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
|
||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
|
||||
import { logbookMatchesFilter, type LogbookSearchFields } from '../utils/logbookFilter.js'
|
||||
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
@@ -20,26 +22,6 @@ interface LogbookDashboardProps {
|
||||
onOpenProfile: () => void
|
||||
}
|
||||
|
||||
function logbookMatchesFilter(lb: DecryptedLogbook, query: string, locale: string): boolean {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
|
||||
if (lb.title.toLowerCase().includes(q)) return true
|
||||
|
||||
const updated = new Date(lb.updatedAt)
|
||||
const year = updated.getFullYear().toString()
|
||||
if (year.includes(q)) return true
|
||||
|
||||
const dateLabel = updated.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).toLowerCase()
|
||||
if (dateLabel.includes(q)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type LogbookSortKey = 'name' | 'date'
|
||||
type LogbookSortDirection = 'asc' | 'desc'
|
||||
|
||||
@@ -72,6 +54,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
const [searchFieldsByLogbookId, setSearchFieldsByLogbookId] = useState<Map<string, LogbookSearchFields>>(
|
||||
() => new Map()
|
||||
)
|
||||
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
|
||||
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -96,6 +81,23 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
loadLogbooks()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const ids = logbooks.map((lb) => lb.id)
|
||||
if (ids.length === 0) {
|
||||
setSearchFieldsByLogbookId(new Map())
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
void loadLogbookSearchFieldsBatch(ids).then((index) => {
|
||||
if (!cancelled) setSearchFieldsByLogbookId(index)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [logbooks])
|
||||
|
||||
const loadLogbooks = async (isRefresh = false) => {
|
||||
if (isRefresh) setRefreshing(true)
|
||||
else setLoading(true)
|
||||
@@ -203,12 +205,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
|
||||
const filterActive = filterQuery.trim().length > 0
|
||||
const filteredOwnedLogbooks = useMemo(
|
||||
() => ownedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
|
||||
[ownedLogbooks, filterQuery, i18n.language]
|
||||
() =>
|
||||
ownedLogbooks.filter((lb) =>
|
||||
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
|
||||
),
|
||||
[ownedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
|
||||
)
|
||||
const filteredSharedLogbooks = useMemo(
|
||||
() => sharedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
|
||||
[sharedLogbooks, filterQuery, i18n.language]
|
||||
() =>
|
||||
sharedLogbooks.filter((lb) =>
|
||||
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
|
||||
),
|
||||
[sharedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
|
||||
)
|
||||
const sortedOwnedLogbooks = useMemo(
|
||||
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
|
||||
|
||||
@@ -10,9 +10,19 @@ import React, {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type ConfirmLeaveChoice = 'stay' | 'save' | 'discard'
|
||||
|
||||
interface DialogContextType {
|
||||
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
||||
showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise<boolean>
|
||||
showConfirmLeave: (
|
||||
message: string,
|
||||
title?: string,
|
||||
stayLabel?: string,
|
||||
saveLabel?: string,
|
||||
discardLabel?: string,
|
||||
options?: { showSave?: boolean }
|
||||
) => Promise<ConfirmLeaveChoice>
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
||||
@@ -34,12 +44,16 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [type, setType] = useState<'alert' | 'confirm'>('alert')
|
||||
const [type, setType] = useState<'alert' | 'confirm' | 'confirm-leave'>('alert')
|
||||
const [confirmLabel, setConfirmLabel] = useState('OK')
|
||||
const [cancelLabel, setCancelLabel] = useState('Cancel')
|
||||
const [saveLabel, setSaveLabel] = useState('')
|
||||
const [discardLabel, setDiscardLabel] = useState('')
|
||||
const [showSaveOption, setShowSaveOption] = useState(false)
|
||||
|
||||
const alertResolveRef = useRef<(() => void) | null>(null)
|
||||
const confirmResolveRef = useRef<((val: boolean) => void) | null>(null)
|
||||
const confirmLeaveResolveRef = useRef<((val: ConfirmLeaveChoice) => void) | null>(null)
|
||||
|
||||
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
|
||||
setMessage(msg)
|
||||
@@ -71,6 +85,36 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
})
|
||||
}, [t])
|
||||
|
||||
const showConfirmLeave = useCallback((
|
||||
msg: string,
|
||||
headerTitle?: string,
|
||||
btnStay?: string,
|
||||
btnSave?: string,
|
||||
btnDiscard?: string,
|
||||
options?: { showSave?: boolean }
|
||||
): Promise<ConfirmLeaveChoice> => {
|
||||
setMessage(msg)
|
||||
setTitle(headerTitle || '')
|
||||
setType('confirm-leave')
|
||||
setCancelLabel(btnStay || t('common.unsaved_changes_stay'))
|
||||
setSaveLabel(btnSave || t('common.unsaved_changes_save_leave'))
|
||||
setDiscardLabel(btnDiscard || t('common.unsaved_changes_discard'))
|
||||
setShowSaveOption(options?.showSave !== false)
|
||||
setIsOpen(true)
|
||||
|
||||
return new Promise<ConfirmLeaveChoice>((resolve) => {
|
||||
confirmLeaveResolveRef.current = resolve
|
||||
})
|
||||
}, [t])
|
||||
|
||||
const closeConfirmLeave = useCallback((choice: ConfirmLeaveChoice) => {
|
||||
setIsOpen(false)
|
||||
if (confirmLeaveResolveRef.current) {
|
||||
confirmLeaveResolveRef.current(choice)
|
||||
confirmLeaveResolveRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
if (type === 'confirm' && confirmResolveRef.current) {
|
||||
@@ -83,19 +127,23 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [type])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (type === 'confirm-leave') {
|
||||
closeConfirmLeave('stay')
|
||||
return
|
||||
}
|
||||
setIsOpen(false)
|
||||
if (confirmResolveRef.current) {
|
||||
confirmResolveRef.current(false)
|
||||
confirmResolveRef.current = null
|
||||
}
|
||||
}, [])
|
||||
}, [type, closeConfirmLeave])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
confirmRef.current?.focus()
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (type === 'confirm') handleCancel()
|
||||
if (type === 'confirm' || type === 'confirm-leave') handleCancel()
|
||||
else handleConfirm()
|
||||
}
|
||||
}
|
||||
@@ -104,8 +152,8 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [isOpen, type, handleCancel, handleConfirm])
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ showAlert, showConfirm }),
|
||||
[showAlert, showConfirm]
|
||||
() => ({ showAlert, showConfirm, showConfirmLeave }),
|
||||
[showAlert, showConfirm, showConfirmLeave]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -114,7 +162,7 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
{isOpen && (
|
||||
<div
|
||||
className="custom-dialog-overlay"
|
||||
onClick={type === 'confirm' ? handleCancel : handleConfirm}
|
||||
onClick={type === 'confirm' || type === 'confirm-leave' ? handleCancel : handleConfirm}
|
||||
>
|
||||
<div
|
||||
className="custom-dialog-card glass scale-in"
|
||||
@@ -133,25 +181,59 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
{message}
|
||||
</p>
|
||||
<div className="custom-dialog-actions">
|
||||
{type === 'confirm' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCancel}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
{type === 'confirm-leave' ? (
|
||||
<>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCancel}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
{showSaveOption && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => closeConfirmLeave('save')}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={() => closeConfirmLeave('discard')}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{discardLabel}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{type === 'confirm' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCancel}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={handleConfirm}
|
||||
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={handleConfirm}
|
||||
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user