feat: implement event journal logging with GPS capture and weather prefill (Plan 03-03)
This commit is contained in:
+9
-9
@@ -11,29 +11,29 @@ See: .planning/PROJECT.md (updated 2026-05-26)
|
||||
|
||||
Phase: 3 of 4 (Master Data & Log entries)
|
||||
Plan: 3 of 3 in current phase
|
||||
Status: Ready to plan
|
||||
Last activity: 2026-05-27 — Plan 03-02 completed (Logbook entry lists, travel header cards, and Freshwater/Fuel auto-calculating consumption grids complete)
|
||||
Status: Completed
|
||||
Last activity: 2026-05-27 — Plan 03-03 completed (Logbook event records, browser Geolocation tracker, and OpenWeatherMap weather API integration complete)
|
||||
|
||||
Progress: [███████░░░] 70%
|
||||
Progress: [████████░░] 80%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
- Total plans completed: 3
|
||||
- Total plans completed: 8
|
||||
- Average duration: 15 min
|
||||
- Total execution time: 0.75 hours
|
||||
- Total execution time: 2.0 hours
|
||||
|
||||
**By Phase:**
|
||||
|
||||
| Phase | Plans | Total | Avg/Plan |
|
||||
|-------|-------|-------|----------|
|
||||
| 1. Foundation, Auth & E2E Crypto | 3/3 | - | - |
|
||||
| 2. Sync Protocol & Multi-Logbooks | 0/2 | - | - |
|
||||
| 3. Master Data & Log entries | 0/3 | - | - |
|
||||
| 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | - |
|
||||
| 2. Sync Protocol & Multi-Logbooks | 2/2 | Completed | - |
|
||||
| 3. Master Data & Log entries | 3/3 | Completed | - |
|
||||
| 4. CSV Export & UI Polish | 0/2 | - | - |
|
||||
|
||||
**Recent Trend:**
|
||||
- Last 5 plans: [01-01, 01-02, 01-03]
|
||||
- Last 5 plans: [02-01, 02-02, 03-01, 03-02, 03-03]
|
||||
- Trend: Stable
|
||||
|
||||
*Updated after each plan completion*
|
||||
|
||||
@@ -984,3 +984,78 @@ body {
|
||||
padding: 6px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Event Journal Styling */
|
||||
.events-scroll-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
background: rgba(11, 12, 16, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for events container */
|
||||
.events-scroll-container::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
.events-scroll-container::-webkit-scrollbar-track {
|
||||
background: rgba(11, 12, 16, 0.2);
|
||||
}
|
||||
.events-scroll-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.events-scroll-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.events-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.events-table th {
|
||||
background: rgba(11, 12, 16, 0.8);
|
||||
color: #fbbf24;
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.events-table td {
|
||||
padding: 12px 16px;
|
||||
color: #e2e8f0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.events-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.table-weather-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: block;
|
||||
margin: -6px 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.remarks-td {
|
||||
min-width: 160px;
|
||||
max-width: 300px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
+2
-5
@@ -6,6 +6,7 @@ import VesselForm from './components/VesselForm.tsx'
|
||||
import CrewForm from './components/CrewForm.tsx'
|
||||
import DeviationForm from './components/DeviationForm.tsx'
|
||||
import LogEntriesList from './components/LogEntriesList.tsx'
|
||||
import SettingsForm from './components/SettingsForm.tsx'
|
||||
import { getActiveMasterKey, logoutUser } from './services/auth.js'
|
||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks } from './services/sync.js'
|
||||
import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
|
||||
@@ -195,11 +196,7 @@ function App() {
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<div className="tab-placeholder">
|
||||
<Settings size={48} className="header-logo" />
|
||||
<h3>{t('nav.settings')}</h3>
|
||||
<p>Logbook sync properties, local cache maintenance, and CSV data tools are configured here.</p>
|
||||
</div>
|
||||
<SettingsForm />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass } from 'lucide-react'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock } from 'lucide-react'
|
||||
|
||||
interface LogEntryEditorProps {
|
||||
entryId: string
|
||||
@@ -12,6 +12,25 @@ interface LogEntryEditorProps {
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
interface LogEvent {
|
||||
time: string
|
||||
mgk: string
|
||||
rwk: string
|
||||
windPressure: string
|
||||
windDirection: string
|
||||
windStrength: string
|
||||
seaState: string
|
||||
weatherIcon: string
|
||||
current: string
|
||||
heel: string
|
||||
sailsOrMotor: string
|
||||
logReading: string
|
||||
distance: string
|
||||
gpsLat: string
|
||||
gpsLng: string
|
||||
remarks: string
|
||||
}
|
||||
|
||||
export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -37,13 +56,32 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
const [signSkipper, setSignSkipper] = useState('')
|
||||
const [signCrew, setSignCrew] = useState('')
|
||||
|
||||
// Events (to preserve Plan 03-03 journal events when editing headers/consumption)
|
||||
const [events, setEvents] = useState<any[]>([])
|
||||
// Events list state
|
||||
const [events, setEvents] = useState<LogEvent[]>([])
|
||||
|
||||
// Add Event Form State
|
||||
const [evTime, setEvTime] = useState('')
|
||||
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 [evWeatherIcon, setEvWeatherIcon] = useState('')
|
||||
const [evCurrent, setEvCurrent] = useState('')
|
||||
const [evHeel, setEvHeel] = useState('')
|
||||
const [evSailsOrMotor, setEvSailsOrMotor] = useState('')
|
||||
const [evLogReading, setEvLogReading] = useState('')
|
||||
const [evDistance, setEvDistance] = useState('')
|
||||
const [evGpsLat, setEvGpsLat] = useState('')
|
||||
const [evGpsLng, setEvGpsLng] = useState('')
|
||||
const [evRemarks, setEvRemarks] = useState('')
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [weatherLoading, setWeatherLoading] = useState(false)
|
||||
|
||||
// Auto-calculate Freshwater Consumption
|
||||
useEffect(() => {
|
||||
@@ -63,7 +101,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
||||
}, [fuelMorning, fuelRefilled, fuelEvening])
|
||||
|
||||
// Load entry
|
||||
// Load entry details
|
||||
useEffect(() => {
|
||||
async function loadEntry() {
|
||||
setLoading(true)
|
||||
@@ -108,6 +146,135 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
loadEntry()
|
||||
}, [entryId])
|
||||
|
||||
const handleGetGps = () => {
|
||||
if (!navigator.geolocation) {
|
||||
alert('Geolocation is not supported by your browser')
|
||||
return
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setEvGpsLat(pos.coords.latitude.toFixed(6))
|
||||
setEvGpsLng(pos.coords.longitude.toFixed(6))
|
||||
},
|
||||
(err) => {
|
||||
console.error('GPS capturing failed:', err)
|
||||
alert(`Failed to retrieve coordinates: ${err.message}`)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleFetchWeather = async () => {
|
||||
if (!evGpsLat || !evGpsLng) {
|
||||
alert(t('settings.gps_error'))
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = localStorage.getItem('owm_api_key')
|
||||
if (!apiKey) {
|
||||
alert(t('settings.no_key'))
|
||||
return
|
||||
}
|
||||
|
||||
setWeatherLoading(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.openweathermap.org/data/2.5/weather?lat=${evGpsLat}&lon=${evGpsLng}&appid=${apiKey}&units=metric`
|
||||
)
|
||||
|
||||
if (!res.ok) throw new Error('Weather API rejected the request')
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
// Convert wind speed m/s to Beaufort scale
|
||||
const mps = data.wind.speed || 0
|
||||
let bft = 0
|
||||
if (mps < 0.3) bft = 0
|
||||
else if (mps < 1.6) bft = 1
|
||||
else if (mps < 3.4) bft = 2
|
||||
else if (mps < 5.5) bft = 3
|
||||
else if (mps < 8.0) bft = 4
|
||||
else if (mps < 10.8) bft = 5
|
||||
else if (mps < 13.9) bft = 6
|
||||
else if (mps < 17.2) bft = 7
|
||||
else if (mps < 20.8) bft = 8
|
||||
else if (mps < 24.5) bft = 9
|
||||
else if (mps < 28.5) bft = 10
|
||||
else if (mps < 32.7) bft = 11
|
||||
else bft = 12
|
||||
|
||||
setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`)
|
||||
setEvWindPressure(String(data.main.pressure || ''))
|
||||
|
||||
// Calculate wind compass direction sector
|
||||
if (data.wind.deg !== undefined) {
|
||||
const deg = data.wind.deg
|
||||
const sectors = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
|
||||
const index = Math.round(deg / 22.5) % 16
|
||||
setEvWindDirection(sectors[index])
|
||||
}
|
||||
|
||||
if (data.weather && data.weather[0]) {
|
||||
setEvWeatherIcon(data.weather[0].icon)
|
||||
}
|
||||
|
||||
alert(t('settings.weather_success'))
|
||||
} catch (err) {
|
||||
console.error('Weather prefilling failed:', err)
|
||||
alert(t('settings.weather_error'))
|
||||
} finally {
|
||||
setWeatherLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddEvent = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!evTime) return
|
||||
|
||||
const newEvent: 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()
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, newEvent])
|
||||
|
||||
// Clear event form fields
|
||||
setEvTime('')
|
||||
setEvMgk('')
|
||||
setEvRwk('')
|
||||
setEvWindPressure('')
|
||||
setEvWindDirection('')
|
||||
setEvWindStrength('')
|
||||
setEvSeaState('')
|
||||
setEvWeatherIcon('')
|
||||
setEvCurrent('')
|
||||
setEvHeel('')
|
||||
setEvSailsOrMotor('')
|
||||
setEvLogReading('')
|
||||
setEvDistance('')
|
||||
setEvGpsLat('')
|
||||
setEvGpsLng('')
|
||||
setEvRemarks('')
|
||||
}
|
||||
|
||||
const handleDeleteEvent = (index: number) => {
|
||||
setEvents((prev) => prev.filter((_, idx) => idx !== index))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
@@ -189,76 +356,93 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="section-title-bar mb-4">
|
||||
<button className="btn-back" onClick={onBack} 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.new_entry')} / {dayOfTravel}</h2>
|
||||
<div className="crew-dashboard-layout">
|
||||
{/* Top Header Controls */}
|
||||
<div className="form-card" style={{ paddingBottom: '20px' }}>
|
||||
<div className="section-title-bar">
|
||||
<button className="btn-back" onClick={onBack} 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>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</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-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}
|
||||
required
|
||||
/>
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<FileText size={20} className="form-icon" />
|
||||
<h3>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}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.day_of_travel')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder="e.g. 1"
|
||||
value={dayOfTravel}
|
||||
onChange={(e) => setDayOfTravel(e.target.value)}
|
||||
disabled={saving}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.day_of_travel')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder="e.g. 1"
|
||||
value={dayOfTravel}
|
||||
onChange={(e) => setDayOfTravel(e.target.value)}
|
||||
disabled={saving}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.departure')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder="Starting port name"
|
||||
value={departure}
|
||||
onChange={(e) => setDeparture(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.destination')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder="Destination port name"
|
||||
value={destination}
|
||||
onChange={(e) => setDestination(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Freshwater and Fuel Consumption */}
|
||||
<div className="form-grid mt-4">
|
||||
<div className="form-grid">
|
||||
{/* Freshwater card */}
|
||||
<div className="member-editor-card glass">
|
||||
<h3 style={{ marginTop: 0, marginBottom: '16px', color: '#fbbf24' }}>{t('logs.freshwater')}</h3>
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Compass size={20} className="form-icon" />
|
||||
<h3>{t('logs.freshwater')}</h3>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.morning')} (L)</label>
|
||||
@@ -300,7 +484,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
<label>{t('logs.consumption')} (L)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text cell-input"
|
||||
className="input-text cell-input text-green"
|
||||
style={{ color: '#4ade80', fontWeight: 'bold' }}
|
||||
value={fwConsumption}
|
||||
disabled
|
||||
@@ -310,8 +494,11 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
</div>
|
||||
|
||||
{/* Fuel card */}
|
||||
<div className="member-editor-card glass">
|
||||
<h3 style={{ marginTop: 0, marginBottom: '16px', color: '#fbbf24' }}>{t('logs.fuel')}</h3>
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Compass size={20} className="form-icon" />
|
||||
<h3>{t('logs.fuel')}</h3>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.morning')} (L)</label>
|
||||
@@ -353,7 +540,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
<label>{t('logs.consumption')} (L)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text cell-input"
|
||||
className="input-text cell-input text-green"
|
||||
style={{ color: '#4ade80', fontWeight: 'bold' }}
|
||||
value={fuelConsumption}
|
||||
disabled
|
||||
@@ -363,9 +550,291 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Sign-Off Signatures */}
|
||||
<div className="member-editor-card glass mt-4">
|
||||
<h3 style={{ marginTop: 0, marginBottom: '16px', color: '#fbbf24' }}>{t('logs.signatures')}</h3>
|
||||
{/* 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>
|
||||
<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>
|
||||
<td>
|
||||
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add New Event Form Sub-Card */}
|
||||
<div className="member-editor-card glass">
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>Add Event Log Record</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>
|
||||
<input
|
||||
type="time"
|
||||
className="input-text"
|
||||
value={evTime}
|
||||
onChange={(e) => setEvTime(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_mgk')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 180"
|
||||
className="input-text"
|
||||
value={evMgk}
|
||||
onChange={(e) => setEvMgk(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_rwk')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 185"
|
||||
className="input-text"
|
||||
value={evRwk}
|
||||
onChange={(e) => setEvRwk(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</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_gps')} (Lat, Lng)</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<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 || !evGpsLng}
|
||||
>
|
||||
<CloudSun size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_wind_direction')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. NNE"
|
||||
className="input-text"
|
||||
value={evWindDirection}
|
||||
onChange={(e) => setEvWindDirection(e.target.value)}
|
||||
disabled={saving || weatherLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-grid mb-4">
|
||||
<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>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_wind_pressure')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 1013 hPa"
|
||||
className="input-text"
|
||||
value={evWindPressure}
|
||||
onChange={(e) => setEvWindPressure(e.target.value)}
|
||||
disabled={saving || weatherLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_sea_state')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 3"
|
||||
className="input-text"
|
||||
value={evSeaState}
|
||||
onChange={(e) => setEvSeaState(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_heel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 5"
|
||||
className="input-text"
|
||||
value={evHeel}
|
||||
onChange={(e) => setEvHeel(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</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="input-group" style={{ gridColumn: '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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleAddEvent}
|
||||
disabled={saving || !evTime}
|
||||
style={{ width: 'auto', padding: '10px 20px', marginLeft: 'auto', display: 'flex' }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Event Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 4: Sign-Off Signatures */}
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Check size={20} className="form-icon" />
|
||||
<h3>{t('logs.signatures')}</h3>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('logs.sign_skipper')} *</label>
|
||||
@@ -395,16 +864,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 4: Logbook journal events placeholder */}
|
||||
<div className="member-editor-card glass mt-4" style={{ borderStyle: 'dashed' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#94a3b8' }}>
|
||||
<Compass size={18} />
|
||||
<span style={{ fontSize: '13.5px', fontWeight: 500 }}>
|
||||
Journal Events and GPS tracking coordinates will be configured here in Plan 03-03.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Controls */}
|
||||
<div className="form-actions mt-4">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Settings, Save, Check } from 'lucide-react'
|
||||
|
||||
export default function SettingsForm() {
|
||||
const { t } = useTranslation()
|
||||
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setSuccess(false)
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('owm_api_key', apiKey.trim())
|
||||
|
||||
setSaving(false)
|
||||
setSuccess(true)
|
||||
setTimeout(() => setSuccess(false), 3000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Settings size={24} className="form-icon" />
|
||||
<div>
|
||||
<h2>{t('settings.title')}</h2>
|
||||
<p className="form-subtitle" style={{ margin: '4px 0 0 0', fontSize: '13px', color: '#94a3b8' }}>
|
||||
{t('settings.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="vessel-form mt-6">
|
||||
<div className="member-editor-card glass">
|
||||
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
|
||||
{t('settings.owm_title')}
|
||||
</h3>
|
||||
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.key_help')}
|
||||
</p>
|
||||
|
||||
<div className="input-group">
|
||||
<label htmlFor="owm-api-key" style={{ display: 'block', fontSize: '13.5px', color: '#94a3b8', marginBottom: '6px', fontWeight: 500 }}>
|
||||
{t('settings.owm_key')}
|
||||
</label>
|
||||
<input
|
||||
id="owm-api-key"
|
||||
type="password"
|
||||
className="input-text"
|
||||
placeholder="e.g. 8b6a7f...d8"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions mt-4">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('settings.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={saving}>
|
||||
<Save size={18} />
|
||||
{saving ? t('settings.saving') : t('settings.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -67,7 +67,25 @@
|
||||
"saved": "Logbuchseite erfolgreich gespeichert!",
|
||||
"loading": "Journal wird geladen...",
|
||||
"delete_entry": "Tag löschen",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?"
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?",
|
||||
"event_title": "Chronologisches Ereignisprotokoll",
|
||||
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
||||
"event_time": "Uhrzeit",
|
||||
"event_mgk": "MgK Kurs",
|
||||
"event_rwk": "RwK Kurs",
|
||||
"event_wind_direction": "Wind-Richtung",
|
||||
"event_wind_strength": "Windstärke",
|
||||
"event_sea_state": "Seegang",
|
||||
"event_weather": "Wetter",
|
||||
"event_log": "Logge (sm)",
|
||||
"event_gps": "GPS-Position",
|
||||
"event_remarks": "Bemerkungen / Vorkommnisse",
|
||||
"gps_btn": "GPS-Koordinaten abrufen",
|
||||
"weather_btn": "OpenWeatherMap Wetter abrufen",
|
||||
"event_wind_pressure": "Luftdruck (hPa)",
|
||||
"event_heel": "Krängung (°)",
|
||||
"event_sails": "Segelführung / Motor",
|
||||
"event_distance": "Distanz (sm)"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Ihre Logbücher",
|
||||
@@ -113,6 +131,20 @@
|
||||
"saving": "Wird gespeichert...",
|
||||
"saved": "Kalibrierungsgitter erfolgreich gespeichert!",
|
||||
"loading": "Kalibrierungstabelle wird geladen..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Systemeinstellungen",
|
||||
"subtitle": "Konfigurieren Sie externe Integrationen und Anmeldedaten.",
|
||||
"owm_title": "Wetter-Integration",
|
||||
"owm_key": "OpenWeatherMap API-Schlüssel",
|
||||
"save": "Konfiguration speichern",
|
||||
"saving": "Wird gespeichert...",
|
||||
"saved": "Einstellungen erfolgreich gespeichert!",
|
||||
"key_help": "Ein API-Schlüssel wird benötigt, um Wetterparameter und Seebedingungen automatisch anhand von GPS-Koordinaten abzurufen.",
|
||||
"no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.",
|
||||
"weather_success": "Wetterdaten erfolgreich abgerufen!",
|
||||
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfen Sie den API-Schlüssel und die Verbindung.",
|
||||
"gps_error": "Bitte ermitteln Sie zuerst die GPS-Koordinaten."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,25 @@
|
||||
"saved": "Logbook page saved successfully!",
|
||||
"loading": "Loading journal...",
|
||||
"delete_entry": "Delete Day",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?"
|
||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||
"event_title": "Chronological Event Logbook",
|
||||
"no_events": "No events logged for this travel day yet.",
|
||||
"event_time": "Time",
|
||||
"event_mgk": "MgK Course",
|
||||
"event_rwk": "RwK Course",
|
||||
"event_wind_direction": "Wind Dir",
|
||||
"event_wind_strength": "Wind Str",
|
||||
"event_sea_state": "Sea State",
|
||||
"event_weather": "Weather",
|
||||
"event_log": "Log (nm)",
|
||||
"event_gps": "GPS Position",
|
||||
"event_remarks": "Remarks / Events",
|
||||
"gps_btn": "Get GPS Location",
|
||||
"weather_btn": "Fetch OpenWeatherMap Weather",
|
||||
"event_wind_pressure": "Barometer (hPa)",
|
||||
"event_heel": "Heel Angle (°)",
|
||||
"event_sails": "Sails / Motor Status",
|
||||
"event_distance": "Distance (nm)"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Your Logbooks",
|
||||
@@ -113,6 +131,20 @@
|
||||
"saving": "Saving...",
|
||||
"saved": "Calibration grid saved successfully!",
|
||||
"loading": "Loading calibration table..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "System Settings",
|
||||
"subtitle": "Configure external integrations and client credentials.",
|
||||
"owm_title": "Weather Integration",
|
||||
"owm_key": "OpenWeatherMap API Key",
|
||||
"save": "Save Configuration",
|
||||
"saving": "Saving...",
|
||||
"saved": "Settings saved successfully!",
|
||||
"key_help": "An API key is required to automatically fetch real-time weather and sea state parameters based on your vessel's GPS coordinates.",
|
||||
"no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.",
|
||||
"weather_success": "Weather details fetched successfully!",
|
||||
"weather_error": "Failed to fetch weather. Check your API key and connection.",
|
||||
"gps_error": "Please fetch GPS coordinates first."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user