feat & docs: implement E2E-encrypted Vessel/Crew profiles forms and Compass Deviation grid
This commit is contained in:
+3
-3
@@ -10,11 +10,11 @@ See: .planning/PROJECT.md (updated 2026-05-26)
|
||||
## Current Position
|
||||
|
||||
Phase: 3 of 4 (Master Data & Log entries)
|
||||
Plan: 1 of 3 in current phase
|
||||
Plan: 2 of 3 in current phase
|
||||
Status: Ready to plan
|
||||
Last activity: 2026-05-27 — Phase 2 completed (Multi-logbooks IndexedDB cache, E2E push/pull sync scheduler, last-write-wins conflict resolution, and live sync status indicators complete)
|
||||
Last activity: 2026-05-27 — Plan 03-01 completed (E2E-encrypted Yacht/Skipper/Crew forms and Compass Deviation grid UI complete)
|
||||
|
||||
Progress: [█████░░░░░] 50%
|
||||
Progress: [██████░░░░] 60%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
|
||||
@@ -783,3 +783,204 @@ body {
|
||||
line-height: 150%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Form and Editor layout structures */
|
||||
.form-card {
|
||||
background: rgba(11, 12, 16, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.form-icon {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.vessel-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.vessel-form label {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.success-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #4ade80;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Crew Dashboard Layout */
|
||||
.crew-dashboard-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-editor-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(212, 175, 55, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.crew-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.crew-member-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.crew-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.crew-card-header h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.crew-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.crew-card-body p {
|
||||
margin: 0;
|
||||
line-height: 140%;
|
||||
}
|
||||
|
||||
.crew-card-body strong {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* Deviation Table Layout */
|
||||
.deviation-grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.deviation-grid-container {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.deviation-cell {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cell-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fbbf24;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.cell-input {
|
||||
text-align: center;
|
||||
padding: 6px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
+6
-15
@@ -2,6 +2,9 @@ import { useState, useEffect } from 'react'
|
||||
import './App.css'
|
||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||
import VesselForm from './components/VesselForm.tsx'
|
||||
import CrewForm from './components/CrewForm.tsx'
|
||||
import DeviationForm from './components/DeviationForm.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'
|
||||
@@ -183,27 +186,15 @@ function App() {
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<div className="tab-placeholder">
|
||||
<Ship size={48} className="header-logo" />
|
||||
<h3>{t('nav.vessel')}</h3>
|
||||
<p>Master vessel profile details such as name, home port, call sign, and MMSI registration are managed here.</p>
|
||||
</div>
|
||||
<VesselForm logbookId={activeLogbookId} />
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<div className="tab-placeholder">
|
||||
<Users size={48} className="header-logo" />
|
||||
<h3>{t('nav.crew')}</h3>
|
||||
<p>Skipper, mate, and crew records conforming to marine credentials list are maintained here.</p>
|
||||
</div>
|
||||
<CrewForm logbookId={activeLogbookId} />
|
||||
)}
|
||||
|
||||
{activeTab === 'deviation' && (
|
||||
<div className="tab-placeholder">
|
||||
<Compass size={48} className="header-logo" />
|
||||
<h3>{t('nav.deviation')}</h3>
|
||||
<p>Magnetic compass deviation table calibration grids and calculations are rendered here.</p>
|
||||
</div>
|
||||
<DeviationForm logbookId={activeLogbookId} />
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 { Users, User, Plus, Trash2, Edit2, Save, X, Check } from 'lucide-react'
|
||||
|
||||
interface CrewFormProps {
|
||||
logbookId: string
|
||||
}
|
||||
|
||||
interface CrewMemberData {
|
||||
name: string
|
||||
address: string
|
||||
birthDate: string
|
||||
phone: string
|
||||
nationality: string
|
||||
passportNumber: string
|
||||
bloodType: string
|
||||
allergies: string
|
||||
diseases: string
|
||||
role: 'skipper' | 'crew'
|
||||
}
|
||||
|
||||
interface DecryptedCrew {
|
||||
payloadId: string
|
||||
data: CrewMemberData
|
||||
}
|
||||
|
||||
export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Skipper profile state
|
||||
const [skipName, setSkipName] = useState('')
|
||||
const [skipAddress, setSkipAddress] = useState('')
|
||||
const [skipBirthDate, setSkipBirthDate] = useState('')
|
||||
const [skipPhone, setSkipPhone] = useState('')
|
||||
const [skipNationality, setSkipNationality] = useState('')
|
||||
const [skipPassport, setSkipPassport] = useState('')
|
||||
const [skipBloodType, setSkipBloodType] = useState('')
|
||||
const [skipAllergies, setSkipAllergies] = useState('')
|
||||
const [skipDiseases, setSkipDiseases] = useState('')
|
||||
|
||||
// Crew list state
|
||||
const [crewList, setCrewList] = useState<DecryptedCrew[]>([])
|
||||
|
||||
// Inline editor modal/form state
|
||||
const [showMemberForm, setShowMemberForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null) // null = adding, string = editing
|
||||
const [memName, setMemName] = useState('')
|
||||
const [memAddress, setMemAddress] = useState('')
|
||||
const [memBirthDate, setMemBirthDate] = useState('')
|
||||
const [memPhone, setMemPhone] = useState('')
|
||||
const [memNationality, setMemNationality] = useState('')
|
||||
const [memPassport, setMemPassport] = useState('')
|
||||
const [memBloodType, setMemBloodType] = useState('')
|
||||
const [memAllergies, setMemAllergies] = useState('')
|
||||
const [memDiseases, setMemDiseases] = useState('')
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [savingSkipper, setSavingSkipper] = useState(false)
|
||||
const [savingMember, setSavingMember] = useState(false)
|
||||
const [skipperSuccess, setSkipperSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadCrewData()
|
||||
}, [logbookId])
|
||||
|
||||
const loadCrewData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
|
||||
const localCrews = await db.crews.where({ logbookId }).toArray()
|
||||
|
||||
const decryptedCrews: DecryptedCrew[] = []
|
||||
|
||||
for (const c of localCrews) {
|
||||
const decrypted = await decryptJson(c.encryptedData, c.iv, c.tag, masterKey)
|
||||
if (decrypted) {
|
||||
if (c.payloadId === 'skipper') {
|
||||
setSkipName(decrypted.name || '')
|
||||
setSkipAddress(decrypted.address || '')
|
||||
setSkipBirthDate(decrypted.birthDate || '')
|
||||
setSkipPhone(decrypted.phone || '')
|
||||
setSkipNationality(decrypted.nationality || '')
|
||||
setSkipPassport(decrypted.passportNumber || '')
|
||||
setSkipBloodType(decrypted.bloodType || '')
|
||||
setSkipAllergies(decrypted.allergies || '')
|
||||
setSkipDiseases(decrypted.diseases || '')
|
||||
} else {
|
||||
decryptedCrews.push({
|
||||
payloadId: c.payloadId,
|
||||
data: decrypted
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setCrewList(decryptedCrews)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load crew files:', err)
|
||||
setError(err.message || 'Decryption failed. Could not load crew profiles.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSkipper = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSavingSkipper(true)
|
||||
setError(null)
|
||||
setSkipperSuccess(false)
|
||||
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
|
||||
const skipperData: CrewMemberData = {
|
||||
name: skipName.trim(),
|
||||
address: skipAddress.trim(),
|
||||
birthDate: skipBirthDate,
|
||||
phone: skipPhone.trim(),
|
||||
nationality: skipNationality.trim(),
|
||||
passportNumber: skipPassport.trim(),
|
||||
bloodType: skipBloodType.trim(),
|
||||
allergies: skipAllergies.trim(),
|
||||
diseases: skipDiseases.trim(),
|
||||
role: 'skipper'
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(skipperData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.crews.put({
|
||||
payloadId: 'skipper',
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'crew',
|
||||
payloadId: 'skipper',
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
setSkipperSuccess(true)
|
||||
setTimeout(() => setSkipperSuccess(false), 3000)
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save skipper profile:', err)
|
||||
setError(err.message || 'Failed to save skipper profile.')
|
||||
} finally {
|
||||
setSavingSkipper(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAddMember = () => {
|
||||
setEditingId(null)
|
||||
setMemName('')
|
||||
setMemAddress('')
|
||||
setMemBirthDate('')
|
||||
setMemPhone('')
|
||||
setMemNationality('')
|
||||
setMemPassport('')
|
||||
setMemBloodType('')
|
||||
setMemAllergies('')
|
||||
setMemDiseases('')
|
||||
setShowMemberForm(true)
|
||||
}
|
||||
|
||||
const openEditMember = (member: DecryptedCrew) => {
|
||||
setEditingId(member.payloadId)
|
||||
setMemName(member.data.name)
|
||||
setMemAddress(member.data.address)
|
||||
setMemBirthDate(member.data.birthDate)
|
||||
setMemPhone(member.data.phone)
|
||||
setMemNationality(member.data.nationality)
|
||||
setMemPassport(member.data.passportNumber)
|
||||
setMemBloodType(member.data.bloodType)
|
||||
setMemAllergies(member.data.allergies)
|
||||
setMemDiseases(member.data.diseases)
|
||||
setShowMemberForm(true)
|
||||
}
|
||||
|
||||
const handleSaveMember = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!memName.trim()) return
|
||||
|
||||
setSavingMember(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
|
||||
const memberData: CrewMemberData = {
|
||||
name: memName.trim(),
|
||||
address: memAddress.trim(),
|
||||
birthDate: memBirthDate,
|
||||
phone: memPhone.trim(),
|
||||
nationality: memNationality.trim(),
|
||||
passportNumber: memPassport.trim(),
|
||||
bloodType: memBloodType.trim(),
|
||||
allergies: memAllergies.trim(),
|
||||
diseases: memDiseases.trim(),
|
||||
role: 'crew'
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(memberData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
const memberId = editingId || window.crypto.randomUUID()
|
||||
const isNew = !editingId
|
||||
|
||||
await db.crews.put({
|
||||
payloadId: memberId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: isNew ? 'create' : 'update',
|
||||
type: 'crew',
|
||||
payloadId: memberId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Update state local list
|
||||
if (isNew) {
|
||||
setCrewList((prev) => [...prev, { payloadId: memberId, data: memberData }])
|
||||
} else {
|
||||
setCrewList((prev) =>
|
||||
prev.map((item) => (item.payloadId === memberId ? { payloadId: memberId, data: memberData } : item))
|
||||
)
|
||||
}
|
||||
|
||||
setShowMemberForm(false)
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save crew member:', err)
|
||||
setError(err.message || 'Failed to save crew member.')
|
||||
} finally {
|
||||
setSavingMember(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteMember = async (memberId: string) => {
|
||||
if (window.confirm(t('crew.delete_confirm'))) {
|
||||
setError(null)
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.crews.delete(memberId)
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'crew',
|
||||
payloadId: memberId,
|
||||
logbookId,
|
||||
data: '',
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
setCrewList((prev) => prev.filter((item) => item.payloadId !== memberId))
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete crew member:', err)
|
||||
setError(err.message || 'Failed to delete crew member.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
<Users className="header-logo spin" size={48} />
|
||||
<p>{t('crew.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="crew-dashboard-layout">
|
||||
{/* Skipper Section */}
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<User size={24} className="form-icon" />
|
||||
<h2>{t('crew.skipper_section')}</h2>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSaveSkipper} className="vessel-form">
|
||||
<div className="form-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('crew.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={skipName}
|
||||
onChange={(e) => setSkipName(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.address')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={skipAddress}
|
||||
onChange={(e) => setSkipAddress(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.birthdate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input-text"
|
||||
value={skipBirthDate}
|
||||
onChange={(e) => setSkipBirthDate(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.phone')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={skipPhone}
|
||||
onChange={(e) => setSkipPhone(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.nationality')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={skipNationality}
|
||||
onChange={(e) => setSkipNationality(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.passport')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={skipPassport}
|
||||
onChange={(e) => setSkipPassport(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.bloodtype')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={skipBloodType}
|
||||
onChange={(e) => setSkipBloodType(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.allergies')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={skipAllergies}
|
||||
onChange={(e) => setSkipAllergies(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group" style={{ gridColumn: 'span 2' }}>
|
||||
<label>{t('crew.diseases')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={skipDiseases}
|
||||
onChange={(e) => setSkipDiseases(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
{skipperSuccess && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('crew.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={savingSkipper || !skipName.trim()}>
|
||||
<Save size={18} />
|
||||
{t('crew.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Crew list section */}
|
||||
<div className="form-card mt-6">
|
||||
<div className="section-title-bar mb-4">
|
||||
<div className="form-header" style={{ margin: 0 }}>
|
||||
<Users size={24} className="form-icon" />
|
||||
<h2>{t('crew.crew_section')}</h2>
|
||||
</div>
|
||||
{crewList.length < 5 && !showMemberForm && (
|
||||
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||
<Plus size={16} />
|
||||
{t('crew.add_crew')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add/Edit member form */}
|
||||
{showMemberForm && (
|
||||
<form onSubmit={handleSaveMember} className="member-editor-card glass mb-6">
|
||||
<div className="editor-header mb-4">
|
||||
<h3>{editingId ? t('crew.edit_crew') : t('crew.add_crew')}</h3>
|
||||
<button type="button" className="btn-icon" onClick={() => setShowMemberForm(false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('crew.name')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={memName}
|
||||
onChange={(e) => setMemName(e.target.value)}
|
||||
disabled={savingMember}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.address')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={memAddress}
|
||||
onChange={(e) => setMemAddress(e.target.value)}
|
||||
disabled={savingMember}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.birthdate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input-text"
|
||||
value={memBirthDate}
|
||||
onChange={(e) => setMemBirthDate(e.target.value)}
|
||||
disabled={savingMember}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.phone')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={memPhone}
|
||||
onChange={(e) => setMemPhone(e.target.value)}
|
||||
disabled={savingMember}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.nationality')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={memNationality}
|
||||
onChange={(e) => setMemNationality(e.target.value)}
|
||||
disabled={savingMember}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.passport')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={memPassport}
|
||||
onChange={(e) => setMemPassport(e.target.value)}
|
||||
disabled={savingMember}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.bloodtype')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={memBloodType}
|
||||
onChange={(e) => setMemBloodType(e.target.value)}
|
||||
disabled={savingMember}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('crew.allergies')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={memAllergies}
|
||||
onChange={(e) => setMemAllergies(e.target.value)}
|
||||
disabled={savingMember}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group" style={{ gridColumn: 'span 2' }}>
|
||||
<label>{t('crew.diseases')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={memDiseases}
|
||||
onChange={(e) => setMemDiseases(e.target.value)}
|
||||
disabled={savingMember}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-actions mt-4">
|
||||
<button type="submit" className="btn primary" disabled={savingMember || !memName.trim()}>
|
||||
<Check size={18} />
|
||||
{t('crew.save_member')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{crewList.length === 0 ? (
|
||||
<div className="dashboard-status-msg">{t('crew.no_crew')}</div>
|
||||
) : (
|
||||
<div className="crew-grid">
|
||||
{crewList.map((m) => (
|
||||
<div key={m.payloadId} className="crew-member-card glass">
|
||||
<div className="crew-card-header">
|
||||
<h4>{m.data.name}</h4>
|
||||
<div className="card-actions">
|
||||
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="crew-card-body">
|
||||
{m.data.birthDate && <p><strong>{t('crew.birthdate')}:</strong> {m.data.birthDate}</p>}
|
||||
{m.data.phone && <p><strong>{t('crew.phone')}:</strong> {m.data.phone}</p>}
|
||||
{m.data.nationality && <p><strong>{t('crew.nationality')}:</strong> {m.data.nationality}</p>}
|
||||
{m.data.passportNumber && <p><strong>{t('crew.passport')}:</strong> {m.data.passportNumber}</p>}
|
||||
{m.data.bloodType && <p><strong>{t('crew.bloodtype')}:</strong> {m.data.bloodType}</p>}
|
||||
{m.data.allergies && <p><strong>{t('crew.allergies')}:</strong> {m.data.allergies}</p>}
|
||||
{m.data.diseases && <p><strong>{t('crew.diseases')}:</strong> {m.data.diseases}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 { Compass, Save, Check } from 'lucide-react'
|
||||
|
||||
interface DeviationFormProps {
|
||||
logbookId: string
|
||||
}
|
||||
|
||||
export default function DeviationForm({ logbookId }: DeviationFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Generate headings: 0, 10, 20, ..., 360 (37 items)
|
||||
const headings = Array.from({ length: 37 }, (_, i) => i * 10)
|
||||
|
||||
// Map representation: heading -> deviation input string (e.g., "1.5", "-2")
|
||||
const [deviations, setDeviations] = useState<Record<number, string>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDeviationTable() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
|
||||
const local = await db.deviations.get(logbookId)
|
||||
if (local) {
|
||||
const decrypted = await decryptJson(local.encryptedData, local.iv, local.tag, masterKey)
|
||||
if (decrypted && decrypted.deviations) {
|
||||
// Map keys back to numbers and set state
|
||||
const map: Record<number, string> = {}
|
||||
Object.entries(decrypted.deviations).forEach(([k, v]) => {
|
||||
map[Number(k)] = String(v)
|
||||
})
|
||||
setDeviations(map)
|
||||
}
|
||||
} else {
|
||||
// Initialize empty map
|
||||
const map: Record<number, string> = {}
|
||||
headings.forEach((h) => {
|
||||
map[h] = ''
|
||||
})
|
||||
setDeviations(map)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load deviation data:', err)
|
||||
setError(err.message || 'Decryption failed. Could not load deviation table.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadDeviationTable()
|
||||
}, [logbookId])
|
||||
|
||||
const handleInputChange = (heading: number, val: string) => {
|
||||
setDeviations((prev) => ({
|
||||
...prev,
|
||||
[heading]: val
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
|
||||
// Parse values, substituting 0 if empty
|
||||
const sanitizedDeviations: Record<number, number> = {}
|
||||
headings.forEach((h) => {
|
||||
const val = deviations[h] || ''
|
||||
const parsed = parseFloat(val.replace('+', '').trim())
|
||||
sanitizedDeviations[h] = isNaN(parsed) ? 0 : parsed
|
||||
})
|
||||
|
||||
const dataToSave = {
|
||||
deviations: sanitizedDeviations
|
||||
}
|
||||
|
||||
// E2E encrypt
|
||||
const encrypted = await encryptJson(dataToSave, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Save locally
|
||||
await db.deviations.put({
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Queue for background sync
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'deviation',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
setSuccess(true)
|
||||
setTimeout(() => setSuccess(false), 3000)
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save deviation table:', err)
|
||||
setError(err.message || 'Failed to save deviation table.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
<Compass className="header-logo spin" size={48} />
|
||||
<p>{t('deviation.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Compass size={24} className="form-icon" />
|
||||
<div>
|
||||
<h2>{t('deviation.title')}</h2>
|
||||
<p className="form-subtitle" style={{ margin: '4px 0 0 0', fontSize: '13px', color: '#94a3b8' }}>
|
||||
{t('deviation.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="vessel-form">
|
||||
<div className="deviation-grid-container mt-6">
|
||||
{headings.map((h) => {
|
||||
const paddedLabel = String(h).padStart(3, '0') + '°'
|
||||
return (
|
||||
<div key={h} className="deviation-cell">
|
||||
<span className="cell-label">{paddedLabel}</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="0.0"
|
||||
className="input-text cell-input"
|
||||
value={deviations[h] || ''}
|
||||
onChange={(e) => handleInputChange(h, e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="form-actions mt-6">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('deviation.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={saving}>
|
||||
<Save size={18} />
|
||||
{saving ? t('deviation.saving') : t('deviation.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 { Ship, Save, Check } from 'lucide-react'
|
||||
|
||||
interface VesselFormProps {
|
||||
logbookId: string
|
||||
}
|
||||
|
||||
export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState('')
|
||||
const [homePort, setHomePort] = useState('')
|
||||
const [charterCompany, setCharterCompany] = useState('')
|
||||
const [owner, setOwner] = useState('')
|
||||
const [registrationNumber, setRegistrationNumber] = useState('')
|
||||
const [callSign, setCallSign] = useState('')
|
||||
const [atis, setAtis] = useState('')
|
||||
const [mmsi, setMmsi] = useState('')
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Load E2E encrypted vessel profile on mount
|
||||
useEffect(() => {
|
||||
async function loadVessel() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
|
||||
const local = await db.yachts.get(logbookId)
|
||||
if (local) {
|
||||
// Decrypt fields
|
||||
const decrypted = await decryptJson(local.encryptedData, local.iv, local.tag, masterKey)
|
||||
if (decrypted) {
|
||||
setName(decrypted.name || '')
|
||||
setHomePort(decrypted.homePort || '')
|
||||
setCharterCompany(decrypted.charterCompany || '')
|
||||
setOwner(decrypted.owner || '')
|
||||
setRegistrationNumber(decrypted.registrationNumber || '')
|
||||
setCallSign(decrypted.callSign || '')
|
||||
setAtis(decrypted.atis || '')
|
||||
setMmsi(decrypted.mmsi || '')
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load vessel data:', err)
|
||||
setError(err.message || 'Decryption failed. Could not load vessel data.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadVessel()
|
||||
}, [logbookId])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
|
||||
const yachtData = {
|
||||
name: name.trim(),
|
||||
homePort: homePort.trim(),
|
||||
charterCompany: charterCompany.trim(),
|
||||
owner: owner.trim(),
|
||||
registrationNumber: registrationNumber.trim(),
|
||||
callSign: callSign.trim(),
|
||||
atis: atis.trim(),
|
||||
mmsi: mmsi.trim()
|
||||
}
|
||||
|
||||
// E2E encrypt
|
||||
const encrypted = await encryptJson(yachtData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Save locally
|
||||
await db.yachts.put({
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Queue for background synchronization
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'yacht',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
setSuccess(true)
|
||||
setTimeout(() => setSuccess(false), 3000)
|
||||
|
||||
// Trigger background sync task
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save vessel data:', err)
|
||||
setError(err.message || 'Failed to save vessel data.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
<Ship className="header-logo spin" size={48} />
|
||||
<p>{t('vessel.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Ship size={24} className="form-icon" />
|
||||
<h2>{t('vessel.title')}</h2>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="vessel-form">
|
||||
<div className="form-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={saving}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.port')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={homePort}
|
||||
onChange={(e) => setHomePort(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.owner')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={owner}
|
||||
onChange={(e) => setOwner(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.charter')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={charterCompany}
|
||||
onChange={(e) => setCharterCompany(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.registration')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={registrationNumber}
|
||||
onChange={(e) => setRegistrationNumber(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.callsign')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={callSign}
|
||||
onChange={(e) => setCallSign(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.atis')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={atis}
|
||||
onChange={(e) => setAtis(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.mmsi')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={mmsi}
|
||||
onChange={(e) => setMmsi(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('vessel.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={saving || !name.trim()}>
|
||||
<Save size={18} />
|
||||
{saving ? t('vessel.saving') : t('vessel.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,8 +33,15 @@
|
||||
"name": "Yachtname",
|
||||
"port": "Heimathafen",
|
||||
"owner": "Eigner",
|
||||
"charter": "Charterfirma",
|
||||
"registration": "Kennzeichen/Zulassungsnummer",
|
||||
"callsign": "Funk-Rufzeichen",
|
||||
"mmsi": "MMSI-Nr"
|
||||
"atis": "ATIS-Nr",
|
||||
"mmsi": "MMSI-Nr",
|
||||
"save": "Schiffsdaten speichern",
|
||||
"saving": "Wird gespeichert...",
|
||||
"saved": "Schiffsdaten erfolgreich gespeichert!",
|
||||
"loading": "Schiffsdaten werden geladen..."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logbuch-Journal",
|
||||
@@ -56,6 +63,39 @@
|
||||
"loading": "Logbücher werden geladen...",
|
||||
"status_synced": "Synchronisiert",
|
||||
"status_local": "Nur lokaler Cache"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- & Crew-Profile",
|
||||
"skipper_section": "Skipper-Profil",
|
||||
"crew_section": "Crew-Liste",
|
||||
"add_crew": "Crew-Mitglied hinzufügen",
|
||||
"edit_crew": "Crew-Mitglied bearbeiten",
|
||||
"no_crew": "Noch keine Crew-Mitglieder hinzugefügt.",
|
||||
"max_crew": "Maximale Anzahl von 5 Crew-Mitgliedern erreicht.",
|
||||
"name": "Name",
|
||||
"address": "Anschrift",
|
||||
"birthdate": "Geburtstag",
|
||||
"phone": "Telefonnummer",
|
||||
"nationality": "Nationalität",
|
||||
"passport": "Pass- / Ausweisnummer",
|
||||
"bloodtype": "Blutgruppe",
|
||||
"allergies": "Allergien",
|
||||
"diseases": "Vorerkrankungen / Krankheiten",
|
||||
"save": "Skipper-Daten speichern",
|
||||
"save_member": "Mitglied speichern",
|
||||
"saved": "Skipper-Profil erfolgreich gespeichert!",
|
||||
"loading": "Crew-Dateien werden geladen...",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie dieses Crew-Mitglied entfernen möchten?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Ablenkungstabelle (Compass Deviation)",
|
||||
"subtitle": "Tragen Sie die Magnetkompass-Ablenkung (Abl.) für Kurse (MgK) von 000° bis 360° in 10°-Schritten ein.",
|
||||
"heading": "MgK",
|
||||
"deviation": "Ablenkung",
|
||||
"save": "Kalibrierungsgitter speichern",
|
||||
"saving": "Wird gespeichert...",
|
||||
"saved": "Kalibrierungsgitter erfolgreich gespeichert!",
|
||||
"loading": "Kalibrierungstabelle wird geladen..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,15 @@
|
||||
"name": "Yacht Name",
|
||||
"port": "Home Port",
|
||||
"owner": "Owner",
|
||||
"charter": "Charter Company",
|
||||
"registration": "Registration Number",
|
||||
"callsign": "Call Sign",
|
||||
"mmsi": "MMSI Number"
|
||||
"atis": "ATIS Number",
|
||||
"mmsi": "MMSI Number",
|
||||
"save": "Save Vessel Data",
|
||||
"saving": "Saving...",
|
||||
"saved": "Vessel details saved successfully!",
|
||||
"loading": "Loading vessel details..."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logbook Journal",
|
||||
@@ -56,6 +63,39 @@
|
||||
"loading": "Loading logbooks...",
|
||||
"status_synced": "Synced",
|
||||
"status_local": "Local Cache Only"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper & Crew Profiles",
|
||||
"skipper_section": "Skipper Profile",
|
||||
"crew_section": "Crew List",
|
||||
"add_crew": "Add Crew Member",
|
||||
"edit_crew": "Edit Crew Member",
|
||||
"no_crew": "No crew members added yet.",
|
||||
"max_crew": "Maximum of 5 crew members reached.",
|
||||
"name": "Full Name",
|
||||
"address": "Address",
|
||||
"birthdate": "Date of Birth",
|
||||
"phone": "Phone Number",
|
||||
"nationality": "Nationality",
|
||||
"passport": "Passport / ID Number",
|
||||
"bloodtype": "Blood Type",
|
||||
"allergies": "Allergies",
|
||||
"diseases": "Medical Conditions / Diseases",
|
||||
"save": "Save Skipper Data",
|
||||
"save_member": "Save Member",
|
||||
"saved": "Skipper profile saved successfully!",
|
||||
"loading": "Loading crew files...",
|
||||
"delete_confirm": "Are you sure you want to remove this crew member?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Compass Deviation Table",
|
||||
"subtitle": "Calibrate magnetic compass headings (MgK) from 000° to 360° in 10° steps.",
|
||||
"heading": "MgK",
|
||||
"deviation": "Deviation",
|
||||
"save": "Save Calibration Grid",
|
||||
"saving": "Saving...",
|
||||
"saved": "Calibration grid saved successfully!",
|
||||
"loading": "Loading calibration table..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user