feat: Tankkapazitäten, Grauwasser und Slider im Journal
Schiffsdaten speichern optionale Tankvolumina; Reisetage erfassen Grauwasser-Füllstand und nutzen Slider bei bekannter Kapazität, inkl. Tooltips und CSV/PDF-Export. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3068,6 +3068,50 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider {
|
||||||
|
width: 100%;
|
||||||
|
margin: 4px 0 2px;
|
||||||
|
accent-color: #4ade80;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-tanks-section {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-tanks-section h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-tanks-help {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-tanks-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* GPS Track Upload & Map Styling */
|
/* GPS Track Upload & Map Styling */
|
||||||
.track-upload-zone {
|
.track-upload-zone {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||||
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||||
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||||
|
import TankLiterInput from './TankLiterInput.tsx'
|
||||||
|
import { extractTankCapacitiesFromYacht, type VesselTankCapacities } from '../utils/tankCapacity.js'
|
||||||
|
|
||||||
function emptyTankLevels() {
|
function emptyTankLevels() {
|
||||||
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
||||||
@@ -50,6 +52,7 @@ function emptyTankLevels() {
|
|||||||
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
|
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
|
||||||
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
|
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
|
||||||
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
|
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
|
||||||
|
const gw = decrypted.greywater as { level?: number } | undefined
|
||||||
const trackDistance = decrypted.trackDistanceNm
|
const trackDistance = decrypted.trackDistanceNm
|
||||||
const trackSpeedMax = decrypted.trackSpeedMaxKn
|
const trackSpeedMax = decrypted.trackSpeedMaxKn
|
||||||
const trackSpeedAvg = decrypted.trackSpeedAvgKn
|
const trackSpeedAvg = decrypted.trackSpeedAvgKn
|
||||||
@@ -72,6 +75,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
|||||||
evening: fuel.evening || 0,
|
evening: fuel.evening || 0,
|
||||||
consumption: fuel.consumption ?? 0
|
consumption: fuel.consumption ?? 0
|
||||||
},
|
},
|
||||||
|
greywater: gw ? { level: gw.level || 0 } : undefined,
|
||||||
trackDistanceNm:
|
trackDistanceNm:
|
||||||
trackDistance != null && trackDistance !== ''
|
trackDistance != null && trackDistance !== ''
|
||||||
? parseFloat(String(trackDistance))
|
? parseFloat(String(trackDistance))
|
||||||
@@ -145,6 +149,9 @@ export default function LogEntryEditor({
|
|||||||
const [fuelEvening, setFuelEvening] = useState('0')
|
const [fuelEvening, setFuelEvening] = useState('0')
|
||||||
const [fuelConsumption, setFuelConsumption] = useState('0')
|
const [fuelConsumption, setFuelConsumption] = useState('0')
|
||||||
|
|
||||||
|
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
||||||
|
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
||||||
|
|
||||||
// Signatures
|
// Signatures
|
||||||
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||||
@@ -249,6 +256,7 @@ export default function LogEntryEditor({
|
|||||||
evening: parseFloat(fuelEvening) || 0,
|
evening: parseFloat(fuelEvening) || 0,
|
||||||
consumption: parseFloat(fuelConsumption) || 0
|
consumption: parseFloat(fuelConsumption) || 0
|
||||||
},
|
},
|
||||||
|
greywater: { level: parseFloat(greywaterLevel) || 0 },
|
||||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
||||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||||
@@ -259,6 +267,7 @@ export default function LogEntryEditor({
|
|||||||
date, dayOfTravel, departure, destination,
|
date, dayOfTravel, departure, destination,
|
||||||
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
||||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||||
|
greywaterLevel,
|
||||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||||
events
|
events
|
||||||
])
|
])
|
||||||
@@ -268,6 +277,8 @@ export default function LogEntryEditor({
|
|||||||
[fuelConsumption, motorHours]
|
[fuelConsumption, motorHours]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tankCapacityTooltip = t('logs.tank_capacity_tooltip')
|
||||||
|
|
||||||
const currentFingerprint = useMemo(() => {
|
const currentFingerprint = useMemo(() => {
|
||||||
const payload = buildPayloadForSigning()
|
const payload = buildPayloadForSigning()
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
@@ -527,11 +538,12 @@ export default function LogEntryEditor({
|
|||||||
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
||||||
}, [fuelMorning, fuelRefilled, fuelEvening])
|
}, [fuelMorning, fuelRefilled, fuelEvening])
|
||||||
|
|
||||||
// Load Yacht Sails
|
// Load yacht sails and tank capacities
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadYachtSails() {
|
async function loadYachtMeta() {
|
||||||
if (readOnly && preloadedYacht?.sails) {
|
if (readOnly && preloadedYacht) {
|
||||||
setYachtSails(preloadedYacht.sails)
|
if (preloadedYacht.sails) setYachtSails(preloadedYacht.sails)
|
||||||
|
setTankCapacities(extractTankCapacitiesFromYacht(preloadedYacht))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -541,16 +553,19 @@ export default function LogEntryEditor({
|
|||||||
const yacht = await db.yachts.get(logbookId)
|
const yacht = await db.yachts.get(logbookId)
|
||||||
if (yacht) {
|
if (yacht) {
|
||||||
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
|
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
|
||||||
if (decrypted && decrypted.sails && Array.isArray(decrypted.sails)) {
|
if (decrypted) {
|
||||||
|
if (decrypted.sails && Array.isArray(decrypted.sails)) {
|
||||||
setYachtSails(decrypted.sails)
|
setYachtSails(decrypted.sails)
|
||||||
}
|
}
|
||||||
|
setTankCapacities(extractTankCapacitiesFromYacht(decrypted))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load yacht sails in editor:', err)
|
console.error('Failed to load yacht meta in editor:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadYachtSails()
|
loadYachtMeta()
|
||||||
}, [logbookId, preloadedYacht])
|
}, [logbookId, preloadedYacht, readOnly])
|
||||||
|
|
||||||
// Load entry details
|
// Load entry details
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -580,6 +595,11 @@ export default function LogEntryEditor({
|
|||||||
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
||||||
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
|
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
|
||||||
}
|
}
|
||||||
|
if (preloadedEntry.greywater) {
|
||||||
|
setGreywaterLevel(String(preloadedEntry.greywater.level || 0))
|
||||||
|
} else {
|
||||||
|
setGreywaterLevel('0')
|
||||||
|
}
|
||||||
|
|
||||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||||
@@ -613,6 +633,11 @@ export default function LogEntryEditor({
|
|||||||
setFuelEvening(String(decrypted.fuel.evening || 0))
|
setFuelEvening(String(decrypted.fuel.evening || 0))
|
||||||
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
|
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
|
||||||
}
|
}
|
||||||
|
if (decrypted.greywater) {
|
||||||
|
setGreywaterLevel(String(decrypted.greywater.level || 0))
|
||||||
|
} else {
|
||||||
|
setGreywaterLevel('0')
|
||||||
|
}
|
||||||
|
|
||||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||||
@@ -1210,41 +1235,35 @@ export default function LogEntryEditor({
|
|||||||
<h3>{t('logs.freshwater')}</h3>
|
<h3>{t('logs.freshwater')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="consumption-grid">
|
<div className="consumption-grid">
|
||||||
<div className="input-group">
|
<TankLiterInput
|
||||||
<label>{t('logs.morning')}</label>
|
id="fw-morning"
|
||||||
<input
|
label={t('logs.morning')}
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fwMorning}
|
value={fwMorning}
|
||||||
onChange={(e) => setFwMorning(e.target.value)}
|
onChange={setFwMorning}
|
||||||
|
maxLiters={tankCapacities.freshwaterCapacityL}
|
||||||
disabled={saving || readOnly}
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
<TankLiterInput
|
||||||
|
id="fw-refilled"
|
||||||
<div className="input-group">
|
label={t('logs.refilled')}
|
||||||
<label>{t('logs.refilled')}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fwRefilled}
|
value={fwRefilled}
|
||||||
onChange={(e) => setFwRefilled(e.target.value)}
|
onChange={setFwRefilled}
|
||||||
|
maxLiters={tankCapacities.freshwaterCapacityL}
|
||||||
disabled={saving || readOnly}
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
<TankLiterInput
|
||||||
|
id="fw-evening"
|
||||||
<div className="input-group">
|
label={t('logs.evening')}
|
||||||
<label>{t('logs.evening')}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fwEvening}
|
value={fwEvening}
|
||||||
onChange={(e) => setFwEvening(e.target.value)}
|
onChange={setFwEvening}
|
||||||
|
maxLiters={tankCapacities.freshwaterCapacityL}
|
||||||
disabled={saving || readOnly}
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.consumption')} (L)</label>
|
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input-text consumption-value"
|
className="input-text consumption-value"
|
||||||
@@ -1252,6 +1271,7 @@ export default function LogEntryEditor({
|
|||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-readonly="true"
|
aria-readonly="true"
|
||||||
|
title={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1264,41 +1284,35 @@ export default function LogEntryEditor({
|
|||||||
<h3>{t('logs.fuel')}</h3>
|
<h3>{t('logs.fuel')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="consumption-grid">
|
<div className="consumption-grid">
|
||||||
<div className="input-group">
|
<TankLiterInput
|
||||||
<label>{t('logs.morning')}</label>
|
id="fuel-morning"
|
||||||
<input
|
label={t('logs.morning')}
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fuelMorning}
|
value={fuelMorning}
|
||||||
onChange={(e) => setFuelMorning(e.target.value)}
|
onChange={setFuelMorning}
|
||||||
|
maxLiters={tankCapacities.fuelCapacityL}
|
||||||
disabled={saving || readOnly}
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
<TankLiterInput
|
||||||
|
id="fuel-refilled"
|
||||||
<div className="input-group">
|
label={t('logs.refilled')}
|
||||||
<label>{t('logs.refilled')}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fuelRefilled}
|
value={fuelRefilled}
|
||||||
onChange={(e) => setFuelRefilled(e.target.value)}
|
onChange={setFuelRefilled}
|
||||||
|
maxLiters={tankCapacities.fuelCapacityL}
|
||||||
disabled={saving || readOnly}
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
<TankLiterInput
|
||||||
|
id="fuel-evening"
|
||||||
<div className="input-group">
|
label={t('logs.evening')}
|
||||||
<label>{t('logs.evening')}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fuelEvening}
|
value={fuelEvening}
|
||||||
onChange={(e) => setFuelEvening(e.target.value)}
|
onChange={setFuelEvening}
|
||||||
|
maxLiters={tankCapacities.fuelCapacityL}
|
||||||
disabled={saving || readOnly}
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.consumption')} (L)</label>
|
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input-text consumption-value"
|
className="input-text consumption-value"
|
||||||
@@ -1306,11 +1320,12 @@ export default function LogEntryEditor({
|
|||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-readonly="true"
|
aria-readonly="true"
|
||||||
|
title={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.fuel_per_motor_hour')}</label>
|
<label title={tankCapacityTooltip}>{t('logs.fuel_per_motor_hour')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input-text consumption-value"
|
className="input-text consumption-value"
|
||||||
@@ -1322,10 +1337,30 @@ export default function LogEntryEditor({
|
|||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-readonly="true"
|
aria-readonly="true"
|
||||||
|
title={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Greywater card */}
|
||||||
|
<div className="form-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<Compass size={20} className="form-icon" />
|
||||||
|
<h3>{t('logs.greywater')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="consumption-grid">
|
||||||
|
<TankLiterInput
|
||||||
|
id="greywater-level"
|
||||||
|
label={t('logs.greywater_level')}
|
||||||
|
value={greywaterLevel}
|
||||||
|
onChange={setGreywaterLevel}
|
||||||
|
maxLiters={tankCapacities.greywaterCapacityL}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 3: Event Journal Entries */}
|
{/* Section 3: Event Journal Entries */}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { clampTankLiters } from '../utils/tankCapacity.js'
|
||||||
|
|
||||||
|
interface TankLiterInputProps {
|
||||||
|
id?: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
maxLiters?: number
|
||||||
|
disabled?: boolean
|
||||||
|
titleTooltip?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInputLiters(value: string): number {
|
||||||
|
const trimmed = value.trim().replace(',', '.')
|
||||||
|
if (!trimmed) return 0
|
||||||
|
const parsed = Number(trimmed)
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TankLiterInput({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
maxLiters,
|
||||||
|
disabled = false,
|
||||||
|
titleTooltip
|
||||||
|
}: TankLiterInputProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const useSlider = maxLiters != null && maxLiters > 0
|
||||||
|
|
||||||
|
const emitValue = useCallback(
|
||||||
|
(liters: number) => {
|
||||||
|
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
|
||||||
|
const str =
|
||||||
|
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
|
||||||
|
onChange(str)
|
||||||
|
},
|
||||||
|
[onChange, maxLiters, useSlider]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNumberBlur = () => {
|
||||||
|
if (!useSlider) return
|
||||||
|
emitValue(parseInputLiters(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
emitValue(Number(e.target.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValue = parseInputLiters(value)
|
||||||
|
const sliderValue = useSlider ? clampTankLiters(numericValue, maxLiters) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="input-group tank-liter-input">
|
||||||
|
<label htmlFor={id} title={titleTooltip}>{label}</label>
|
||||||
|
{useSlider && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="tank-liter-slider"
|
||||||
|
min={0}
|
||||||
|
max={maxLiters}
|
||||||
|
step={1}
|
||||||
|
value={sliderValue}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
disabled={disabled}
|
||||||
|
title={titleTooltip}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={maxLiters}
|
||||||
|
aria-valuenow={sliderValue}
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
<div className="tank-liter-slider-hint" aria-hidden="true">
|
||||||
|
{t('logs.tank_slider_of_max', {
|
||||||
|
current: sliderValue,
|
||||||
|
max: maxLiters
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
className="input-text"
|
||||||
|
value={value}
|
||||||
|
onChange={handleNumberChange}
|
||||||
|
onBlur={handleNumberBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
min={0}
|
||||||
|
max={useSlider ? maxLiters : undefined}
|
||||||
|
step="any"
|
||||||
|
title={titleTooltip}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { encryptJson, decryptJson } from '../services/crypto.js'
|
|||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
|
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
|
||||||
|
import { parseOptionalTankLiters, tankCapacityInputFromStored } from '../utils/tankCapacity.js'
|
||||||
|
|
||||||
interface VesselFormProps {
|
interface VesselFormProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -47,6 +48,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
const [mmsi, setMmsi] = useState('')
|
const [mmsi, setMmsi] = useState('')
|
||||||
const [sails, setSails] = useState<string[]>([])
|
const [sails, setSails] = useState<string[]>([])
|
||||||
const [newSailName, setNewSailName] = useState('')
|
const [newSailName, setNewSailName] = useState('')
|
||||||
|
const [freshwaterCapacityL, setFreshwaterCapacityL] = useState('')
|
||||||
|
const [fuelCapacityL, setFuelCapacityL] = useState('')
|
||||||
|
const [greywaterCapacityL, setGreywaterCapacityL] = useState('')
|
||||||
|
|
||||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const [photo, setPhoto] = useState<string | null>(null)
|
const [photo, setPhoto] = useState<string | null>(null)
|
||||||
@@ -78,6 +82,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
setMmsi(preloadedData.mmsi || '')
|
setMmsi(preloadedData.mmsi || '')
|
||||||
setSails(preloadedData.sails || [])
|
setSails(preloadedData.sails || [])
|
||||||
setPhoto(preloadedData.photo || null)
|
setPhoto(preloadedData.photo || null)
|
||||||
|
setFreshwaterCapacityL(tankCapacityInputFromStored(preloadedData.freshwaterCapacityL))
|
||||||
|
setFuelCapacityL(tankCapacityInputFromStored(preloadedData.fuelCapacityL))
|
||||||
|
setGreywaterCapacityL(tankCapacityInputFromStored(preloadedData.greywaterCapacityL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +110,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
setMmsi(decrypted.mmsi || '')
|
setMmsi(decrypted.mmsi || '')
|
||||||
setSails(decrypted.sails || [])
|
setSails(decrypted.sails || [])
|
||||||
setPhoto(decrypted.photo || null)
|
setPhoto(decrypted.photo || null)
|
||||||
|
setFreshwaterCapacityL(tankCapacityInputFromStored(decrypted.freshwaterCapacityL))
|
||||||
|
setFuelCapacityL(tankCapacityInputFromStored(decrypted.fuelCapacityL))
|
||||||
|
setGreywaterCapacityL(tankCapacityInputFromStored(decrypted.greywaterCapacityL))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -201,12 +211,19 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
let parsedLengthM: number | undefined
|
let parsedLengthM: number | undefined
|
||||||
let parsedDraftM: number | undefined
|
let parsedDraftM: number | undefined
|
||||||
let parsedAirDraftM: number | undefined
|
let parsedAirDraftM: number | undefined
|
||||||
|
let parsedFreshwaterCapacityL: number | undefined
|
||||||
|
let parsedFuelCapacityL: number | undefined
|
||||||
|
let parsedGreywaterCapacityL: number | undefined
|
||||||
try {
|
try {
|
||||||
parsedLengthM = parseOptionalMetricMeters(lengthM)
|
parsedLengthM = parseOptionalMetricMeters(lengthM)
|
||||||
parsedDraftM = parseOptionalMetricMeters(draftM)
|
parsedDraftM = parseOptionalMetricMeters(draftM)
|
||||||
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
|
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
|
||||||
} catch {
|
parsedFreshwaterCapacityL = parseOptionalTankLiters(freshwaterCapacityL)
|
||||||
setError(t('vessel.invalid_metric'))
|
parsedFuelCapacityL = parseOptionalTankLiters(fuelCapacityL)
|
||||||
|
parsedGreywaterCapacityL = parseOptionalTankLiters(greywaterCapacityL)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : ''
|
||||||
|
setError(msg === 'invalid_tank_liters' ? t('vessel.invalid_tank_liters') : t('vessel.invalid_metric'))
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -217,6 +234,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
lengthM: parsedLengthM,
|
lengthM: parsedLengthM,
|
||||||
draftM: parsedDraftM,
|
draftM: parsedDraftM,
|
||||||
airDraftM: parsedAirDraftM,
|
airDraftM: parsedAirDraftM,
|
||||||
|
freshwaterCapacityL: parsedFreshwaterCapacityL,
|
||||||
|
fuelCapacityL: parsedFuelCapacityL,
|
||||||
|
greywaterCapacityL: parsedGreywaterCapacityL,
|
||||||
homePort: homePort.trim(),
|
homePort: homePort.trim(),
|
||||||
charterCompany: charterCompany.trim(),
|
charterCompany: charterCompany.trim(),
|
||||||
owner: owner.trim(),
|
owner: owner.trim(),
|
||||||
@@ -480,6 +500,49 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="vessel-tanks-section">
|
||||||
|
<h3>{t('vessel.tanks_section')}</h3>
|
||||||
|
<p className="vessel-tanks-help">{t('vessel.tanks_help')}</p>
|
||||||
|
<div className="vessel-tanks-grid">
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.freshwater_capacity_l')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="input-text"
|
||||||
|
value={freshwaterCapacityL}
|
||||||
|
onChange={(e) => setFreshwaterCapacityL(e.target.value)}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.fuel_capacity_l')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="input-text"
|
||||||
|
value={fuelCapacityL}
|
||||||
|
onChange={(e) => setFuelCapacityL(e.target.value)}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.greywater_capacity_l')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="input-text"
|
||||||
|
value={greywaterCapacityL}
|
||||||
|
onChange={(e) => setGreywaterCapacityL(e.target.value)}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="sails-section">
|
<div className="sails-section">
|
||||||
<h3>{t('vessel.sails_list')}</h3>
|
<h3>{t('vessel.sails_list')}</h3>
|
||||||
<p className="help-text">{t('vessel.sails_help')}</p>
|
<p className="help-text">{t('vessel.sails_help')}</p>
|
||||||
|
|||||||
@@ -117,7 +117,13 @@
|
|||||||
"no_sails": "Keine Segel hinterlegt.",
|
"no_sails": "Keine Segel hinterlegt.",
|
||||||
"photo_add": "Foto hinzufügen",
|
"photo_add": "Foto hinzufügen",
|
||||||
"photo_change": "Foto ändern",
|
"photo_change": "Foto ändern",
|
||||||
"photo_delete": "Foto löschen"
|
"photo_delete": "Foto löschen",
|
||||||
|
"tanks_section": "Tanks (Fassungsvermögen)",
|
||||||
|
"tanks_help": "Optional in Liter — ermöglicht Slider im Journal bei bekannten Tankgrößen.",
|
||||||
|
"freshwater_capacity_l": "Trinkwasser (Liter)",
|
||||||
|
"fuel_capacity_l": "Treibstoff (Liter)",
|
||||||
|
"greywater_capacity_l": "Grauwasser (Liter)",
|
||||||
|
"invalid_tank_liters": "Ungültiger Zahlenwert — bitte Liter als Zahl eingeben (z. B. 200)."
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "Logbuch-Journal",
|
"title": "Logbuch-Journal",
|
||||||
@@ -138,6 +144,10 @@
|
|||||||
"route": "Reise von/nach",
|
"route": "Reise von/nach",
|
||||||
"freshwater": "Frischwasser (Liter)",
|
"freshwater": "Frischwasser (Liter)",
|
||||||
"fuel": "Treibstoff / Fuel (Liter)",
|
"fuel": "Treibstoff / Fuel (Liter)",
|
||||||
|
"greywater": "Grauwasser (Liter)",
|
||||||
|
"greywater_level": "Füllstand",
|
||||||
|
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||||
|
"tank_capacity_tooltip": "Wenn in den Schiffsdaten die Tank-Fassungsvermögen (Liter) hinterlegt sind, kannst du Füllstände hier per Slider eingeben.",
|
||||||
"morning": "Stand morgens",
|
"morning": "Stand morgens",
|
||||||
"refilled": "Nachgefüllt",
|
"refilled": "Nachgefüllt",
|
||||||
"evening": "Stand abends",
|
"evening": "Stand abends",
|
||||||
|
|||||||
@@ -117,7 +117,13 @@
|
|||||||
"no_sails": "No sails defined.",
|
"no_sails": "No sails defined.",
|
||||||
"photo_add": "Add Photo",
|
"photo_add": "Add Photo",
|
||||||
"photo_change": "Change Photo",
|
"photo_change": "Change Photo",
|
||||||
"photo_delete": "Delete Photo"
|
"photo_delete": "Delete Photo",
|
||||||
|
"tanks_section": "Tanks (capacity)",
|
||||||
|
"tanks_help": "Optional, in liters — enables sliders in the journal when tank sizes are known.",
|
||||||
|
"freshwater_capacity_l": "Freshwater (liters)",
|
||||||
|
"fuel_capacity_l": "Fuel (liters)",
|
||||||
|
"greywater_capacity_l": "Greywater (liters)",
|
||||||
|
"invalid_tank_liters": "Invalid number — please enter capacity in liters (e.g. 200)."
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "Logbook Journal",
|
"title": "Logbook Journal",
|
||||||
@@ -138,6 +144,10 @@
|
|||||||
"route": "Route / Journey",
|
"route": "Route / Journey",
|
||||||
"freshwater": "Freshwater (Liters)",
|
"freshwater": "Freshwater (Liters)",
|
||||||
"fuel": "Fuel (Liters)",
|
"fuel": "Fuel (Liters)",
|
||||||
|
"greywater": "Greywater (Liters)",
|
||||||
|
"greywater_level": "Fill level",
|
||||||
|
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||||
|
"tank_capacity_tooltip": "If tank capacities (liters) are set in vessel master data, you can enter fill levels here using sliders.",
|
||||||
"morning": "Morning Level",
|
"morning": "Morning Level",
|
||||||
"refilled": "Refilled",
|
"refilled": "Refilled",
|
||||||
"evening": "Evening Level",
|
"evening": "Evening Level",
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
'Latitude', 'Longitude', 'Remarks',
|
'Latitude', 'Longitude', 'Remarks',
|
||||||
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
||||||
'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)',
|
'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)',
|
||||||
|
'Greywater Level (L)',
|
||||||
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
|
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -123,6 +124,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
const fuelR = entry.fuel?.refilled ?? '';
|
const fuelR = entry.fuel?.refilled ?? '';
|
||||||
const fuelE = entry.fuel?.evening ?? '';
|
const fuelE = entry.fuel?.evening ?? '';
|
||||||
const fuelCons = entry.fuel?.consumption ?? '';
|
const fuelCons = entry.fuel?.consumption ?? '';
|
||||||
|
const greywaterLevel = entry.greywater?.level ?? '';
|
||||||
|
|
||||||
const eventsList = entry.events || [];
|
const eventsList = entry.events || [];
|
||||||
if (eventsList.length === 0) {
|
if (eventsList.length === 0) {
|
||||||
@@ -137,6 +139,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
'', '', '',
|
'', '', '',
|
||||||
fwM, fwR, fwE, fwCons,
|
fwM, fwR, fwE, fwCons,
|
||||||
fuelM, fuelR, fuelE, fuelCons,
|
fuelM, fuelR, fuelE, fuelCons,
|
||||||
|
greywaterLevel,
|
||||||
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
||||||
].map(escapeCsvValue));
|
].map(escapeCsvValue));
|
||||||
} else {
|
} else {
|
||||||
@@ -153,6 +156,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
||||||
fwM, fwR, fwE, fwCons,
|
fwM, fwR, fwE, fwCons,
|
||||||
fuelM, fuelR, fuelE, fuelCons,
|
fuelM, fuelR, fuelE, fuelCons,
|
||||||
|
greywaterLevel,
|
||||||
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
||||||
].map(escapeCsvValue));
|
].map(escapeCsvValue));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface DemoDaySpec {
|
|||||||
filename: string
|
filename: string
|
||||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
greywaterLevel?: number
|
||||||
motorHours?: number
|
motorHours?: number
|
||||||
events: Array<Record<string, string>>
|
events: Array<Record<string, string>>
|
||||||
}
|
}
|
||||||
@@ -69,6 +70,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
|||||||
filename: 'kiel-laboe.gpx',
|
filename: 'kiel-laboe.gpx',
|
||||||
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
|
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
|
||||||
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
|
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
|
||||||
|
greywaterLevel: 25,
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
time: '10:15',
|
time: '10:15',
|
||||||
@@ -101,6 +103,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
|||||||
filename: 'laboe-damp.gpx',
|
filename: 'laboe-damp.gpx',
|
||||||
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
|
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
|
||||||
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
|
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
|
||||||
|
greywaterLevel: 38,
|
||||||
motorHours: 1.5,
|
motorHours: 1.5,
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
@@ -134,6 +137,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
|||||||
filename: 'damp-schleimuende.gpx',
|
filename: 'damp-schleimuende.gpx',
|
||||||
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
|
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
|
||||||
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
|
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
|
||||||
|
greywaterLevel: 52,
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
time: '08:30',
|
time: '08:30',
|
||||||
@@ -176,7 +180,10 @@ export function buildDemoYachtData(): Record<string, unknown> {
|
|||||||
atis: '',
|
atis: '',
|
||||||
mmsi: '',
|
mmsi: '',
|
||||||
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
|
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
|
||||||
photo: null
|
photo: null,
|
||||||
|
freshwaterCapacityL: 200,
|
||||||
|
fuelCapacityL: 100,
|
||||||
|
greywaterCapacityL: 80
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +251,10 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
|||||||
events: day.events
|
events: day.events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
|
||||||
|
entryPayload.greywater = { level: day.greywaterLevel }
|
||||||
|
}
|
||||||
|
|
||||||
if (stats) {
|
if (stats) {
|
||||||
entryPayload.trackDistanceNm = stats.distanceNm
|
entryPayload.trackDistanceNm = stats.distanceNm
|
||||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||||
@@ -303,6 +314,10 @@ export function buildDemoEntryPayloads(): Array<{
|
|||||||
events: day.events
|
events: day.events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
|
||||||
|
entryPayload.greywater = { level: day.greywaterLevel }
|
||||||
|
}
|
||||||
|
|
||||||
if (stats) {
|
if (stats) {
|
||||||
entryPayload.trackDistanceNm = stats.distanceNm
|
entryPayload.trackDistanceNm = stats.distanceNm
|
||||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||||
|
|||||||
@@ -197,13 +197,15 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
doc.text('VERBRAUCHSWERTE / CONSUMPTON STATS', 10, footerY + 3);
|
doc.text('VERBRAUCHSWERTE / CONSUMPTON STATS', 10, footerY + 3);
|
||||||
|
|
||||||
let fwY = footerY + 5;
|
let fwY = footerY + 5;
|
||||||
doc.rect(10, fwY, 110, rowHeight * 3, 'S');
|
const tankRows = 4;
|
||||||
|
doc.rect(10, fwY, 110, rowHeight * tankRows, 'S');
|
||||||
doc.line(10, fwY + rowHeight, 120, fwY + rowHeight);
|
doc.line(10, fwY + rowHeight, 120, fwY + rowHeight);
|
||||||
doc.line(10, fwY + rowHeight * 2, 120, fwY + rowHeight * 2);
|
doc.line(10, fwY + rowHeight * 2, 120, fwY + rowHeight * 2);
|
||||||
doc.line(40, fwY, 40, fwY + rowHeight * 3);
|
doc.line(10, fwY + rowHeight * 3, 120, fwY + rowHeight * 3);
|
||||||
doc.line(60, fwY, 60, fwY + rowHeight * 3);
|
doc.line(40, fwY, 40, fwY + rowHeight * tankRows);
|
||||||
doc.line(80, fwY, 80, fwY + rowHeight * 3);
|
doc.line(60, fwY, 60, fwY + rowHeight * tankRows);
|
||||||
doc.line(100, fwY, 100, fwY + rowHeight * 3);
|
doc.line(80, fwY, 80, fwY + rowHeight * tankRows);
|
||||||
|
doc.line(100, fwY, 100, fwY + rowHeight * tankRows);
|
||||||
|
|
||||||
doc.setFont('Helvetica', 'bold');
|
doc.setFont('Helvetica', 'bold');
|
||||||
doc.setFontSize(7.5);
|
doc.setFontSize(7.5);
|
||||||
@@ -226,6 +228,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
doc.text(String(entry.fuel?.evening ?? '0'), 81, fwY + rowHeight * 2 + 4.2);
|
doc.text(String(entry.fuel?.evening ?? '0'), 81, fwY + rowHeight * 2 + 4.2);
|
||||||
doc.text(String(entry.fuel?.consumption ?? '0'), 101, fwY + rowHeight * 2 + 4.2);
|
doc.text(String(entry.fuel?.consumption ?? '0'), 101, fwY + rowHeight * 2 + 4.2);
|
||||||
|
|
||||||
|
doc.text('Grauwasser', 11, fwY + rowHeight * 3 + 4.2);
|
||||||
|
doc.text('—', 41, fwY + rowHeight * 3 + 4.2);
|
||||||
|
doc.text('—', 61, fwY + rowHeight * 3 + 4.2);
|
||||||
|
doc.text(String(entry.greywater?.level ?? '0'), 81, fwY + rowHeight * 3 + 4.2);
|
||||||
|
doc.text('—', 101, fwY + rowHeight * 3 + 4.2);
|
||||||
|
|
||||||
// Signatures Box
|
// Signatures Box
|
||||||
let sigX = 130;
|
let sigX = 130;
|
||||||
let sigY = footerY + 5;
|
let sigY = footerY + 5;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
buildLogEntryPayload,
|
||||||
hasUnsavedEventDraft,
|
hasUnsavedEventDraft,
|
||||||
isLogEventDraftEmpty,
|
isLogEventDraftEmpty,
|
||||||
normalizeLogEvent,
|
normalizeLogEvent,
|
||||||
@@ -40,3 +41,25 @@ describe('logEntryPayload event drafts', () => {
|
|||||||
expect(hasUnsavedEventDraft(filledDraft(), 0, events)).toBe(false)
|
expect(hasUnsavedEventDraft(filledDraft(), 0, events)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('buildLogEntryPayload greywater', () => {
|
||||||
|
const base = {
|
||||||
|
date: '2026-05-31',
|
||||||
|
dayOfTravel: '1',
|
||||||
|
departure: 'Kiel',
|
||||||
|
destination: 'Laboe',
|
||||||
|
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
events: [] as LogEventPayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
it('includes greywater when level > 0', () => {
|
||||||
|
const payload = buildLogEntryPayload({ ...base, greywater: { level: 45 } })
|
||||||
|
expect(payload.greywater).toEqual({ level: 45 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits greywater when level is 0', () => {
|
||||||
|
const payload = buildLogEntryPayload({ ...base, greywater: { level: 0 } })
|
||||||
|
expect(payload.greywater).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export interface LogEntryPayloadInput {
|
|||||||
destination: string
|
destination: string
|
||||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
greywater?: { level: number }
|
||||||
trackDistanceNm?: number
|
trackDistanceNm?: number
|
||||||
trackSpeedMaxKn?: number
|
trackSpeedMaxKn?: number
|
||||||
trackSpeedAvgKn?: number
|
trackSpeedAvgKn?: number
|
||||||
@@ -169,5 +170,12 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
|
|||||||
payload.motorHours = Number(input.motorHours.toFixed(2))
|
payload.motorHours = Number(input.motorHours.toFixed(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.greywater !== undefined) {
|
||||||
|
const level = Number(input.greywater.level) || 0
|
||||||
|
if (level > 0) {
|
||||||
|
payload.greywater = { level: Number(level.toFixed(1)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
|
|||||||
export interface LogEntryTankSource {
|
export interface LogEntryTankSource {
|
||||||
freshwater?: Partial<TankLevels>
|
freshwater?: Partial<TankLevels>
|
||||||
fuel?: Partial<TankLevels>
|
fuel?: Partial<TankLevels>
|
||||||
|
greywater?: { level?: number }
|
||||||
destination?: string
|
destination?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
clampTankLiters,
|
||||||
|
extractTankCapacitiesFromYacht,
|
||||||
|
formatTankLitersForInput,
|
||||||
|
parseOptionalTankLiters,
|
||||||
|
tankCapacityInputFromStored
|
||||||
|
} from './tankCapacity.js'
|
||||||
|
|
||||||
|
describe('tankCapacity', () => {
|
||||||
|
it('parses optional liters with comma decimal', () => {
|
||||||
|
expect(parseOptionalTankLiters('200')).toBe(200)
|
||||||
|
expect(parseOptionalTankLiters('12,5')).toBe(12.5)
|
||||||
|
expect(parseOptionalTankLiters('')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects negative or invalid liters', () => {
|
||||||
|
expect(() => parseOptionalTankLiters('-1')).toThrow('invalid_tank_liters')
|
||||||
|
expect(() => parseOptionalTankLiters('abc')).toThrow('invalid_tank_liters')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts capacities from yacht payload', () => {
|
||||||
|
expect(
|
||||||
|
extractTankCapacitiesFromYacht({
|
||||||
|
freshwaterCapacityL: 300,
|
||||||
|
fuelCapacityL: 120,
|
||||||
|
greywaterCapacityL: 80
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
freshwaterCapacityL: 300,
|
||||||
|
fuelCapacityL: 120,
|
||||||
|
greywaterCapacityL: 80
|
||||||
|
})
|
||||||
|
expect(extractTankCapacitiesFromYacht({ name: 'Test' })).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats stored capacity for input', () => {
|
||||||
|
expect(tankCapacityInputFromStored(150)).toBe('150')
|
||||||
|
expect(formatTankLitersForInput(12.5)).toBe('12.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps liters to max when set', () => {
|
||||||
|
expect(clampTankLiters(250, 200)).toBe(200)
|
||||||
|
expect(clampTankLiters(-5, 200)).toBe(0)
|
||||||
|
expect(clampTankLiters(50)).toBe(50)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { formatTankLiters } from './logEntryTankLevels.js'
|
||||||
|
|
||||||
|
export interface VesselTankCapacities {
|
||||||
|
freshwaterCapacityL?: number
|
||||||
|
fuelCapacityL?: number
|
||||||
|
greywaterCapacityL?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOptionalTankLiters(input: string): number | undefined {
|
||||||
|
const trimmed = input.trim().replace(',', '.')
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
const parsed = Number(trimmed)
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
throw new Error('invalid_tank_liters')
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTankLitersForInput(liters: number): string {
|
||||||
|
return formatTankLiters(liters)
|
||||||
|
}
|
||||||
|
|
||||||
|
function capacityFromStored(value: unknown): number | undefined {
|
||||||
|
if (value == null || value === '') return undefined
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim().replace(',', '.')
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
const parsed = Number(trimmed)
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) return parsed
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tankCapacityInputFromStored(value: unknown): string {
|
||||||
|
const n = capacityFromStored(value)
|
||||||
|
return n != null ? formatTankLitersForInput(n) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTankCapacitiesFromYacht(decrypted: unknown): VesselTankCapacities {
|
||||||
|
if (!decrypted || typeof decrypted !== 'object') return {}
|
||||||
|
const y = decrypted as Record<string, unknown>
|
||||||
|
const capacities: VesselTankCapacities = {}
|
||||||
|
const fw = capacityFromStored(y.freshwaterCapacityL)
|
||||||
|
const fuel = capacityFromStored(y.fuelCapacityL)
|
||||||
|
const gw = capacityFromStored(y.greywaterCapacityL)
|
||||||
|
if (fw != null) capacities.freshwaterCapacityL = fw
|
||||||
|
if (fuel != null) capacities.fuelCapacityL = fuel
|
||||||
|
if (gw != null) capacities.greywaterCapacityL = gw
|
||||||
|
return capacities
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp numeric liter value to [0, max] when max is known. */
|
||||||
|
export function clampTankLiters(value: number, maxLiters?: number): number {
|
||||||
|
const clamped = Math.max(0, value)
|
||||||
|
if (maxLiters != null && maxLiters > 0) {
|
||||||
|
return Math.min(clamped, maxLiters)
|
||||||
|
}
|
||||||
|
return clamped
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user