Add a Drones fleet section that fills itself in on connect

The fleet lived as a tab inside the Logbook, which buried it, and every
drone had to be typed in by hand — model, serial and firmware copied off
an airframe the app was already talking to.

Promote it to its own nav section above Logbook, and let a connecting
drone register itself. The Fly App already forwarded model, serial and
firmware upstream; the hub was keeping only the model. It now carries the
identity through to DeviceState, and the Web App offers it to a new
POST /api/drones/auto, which upserts keyed by serial. The auto path only
writes what the aircraft is authoritative about (model, both firmware
versions) and never touches what the pilot curates.

Serial and the firmware versions resolve on their own schedules after
connect — the serial in seconds, the aircraft firmware sometimes a minute
later — so nothing along the path treats an absent value as a cleared one,
and a later event filling firmware in still reaches the server. The auto
call rides every telemetry frame, so the client remembers the identity
tuple it last sent and only a change goes out; a 4xx is the server's
settled answer and is not retried, or one drone connected for an hour
would mean one request per frame for an hour.

New fields on drones: firmware, controller_firmware, and registration for
the FAA/CAA aircraft number — distinct from operator_number, which stays
the EU operator ID. Controller firmware is the remote controller's own
version, read from its component; the flight controller's version is a
different quantity and stays off this field (see 002e484). name becomes
optional and is now the pilot's custom name: auto-added drones arrive
unnamed, so the API serves a computed displayName (name, else model +
serial) for the fleet table, the flight picker and the CSV export. A
unique index on serial is what keeps the find-then-create path from
forking a drone's history across two records.

The schema is applied to the remote PocketBase; the migration is here for
fresh deployments, which the remote does not read.

Verified against a simulated device over the real socket with identity
resolving late: one record from four events, both firmware versions
filled, curated fields intact across re-registration, and a drone deleted
while connected coming back on the next frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 4.8 committed 2026-07-16 17:04:12 +02:00
1 parent 3e066c0a99
commit 33595c99e8
19 files changed
+931 -426

No files matched your search

+16
View File
@@ -346,6 +346,22 @@ export async function createDrone(drone) {
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
// Upsert the drone a connected device just reported, keyed by serial. Safe to
// call on every connection event: the server refreshes an existing entry rather
// than duplicating it, and answers { created, updated }.
export async function autoAddDrone(identity) {
try {
const r = await fetch('/bff/drones/auto', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(identity),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
} catch {
return { ok: false, status: 0, body: {} }
}
}
export async function updateDrone(id, drone) {
const r = await fetch(`/bff/drones/${encodeURIComponent(id)}`, {
method: 'PATCH',
+64 -1
View File
@@ -5,9 +5,10 @@ import BrandMark from './BrandMark.vue'
import Icon from './Icon.vue'
import Settings from './Settings.vue'
import Logbook from './Logbook.vue'
import Drones from './Drones.vue'
import Documents from './Documents.vue'
import Toggle from './settings/Toggle.vue'
import { getDevices, sendCommand, getOpenSkyStates, getOpenWeatherCurrent } from '../api.js'
import { getDevices, sendCommand, getOpenSkyStates, getOpenWeatherCurrent, autoAddDrone } from '../api.js'
import { formatTime, prefs } from '../prefs.js'
import { countryForPoint, bboxForCountry } from '../countries.js'
@@ -238,6 +239,7 @@ const NAV = [
['radio', 'Live flights'],
['route', 'Routes'],
['calendar', 'Schedule'],
['drone', 'Drones'],
['book', 'Logbook'],
['fileText', 'Documents'],
['server', 'Drives'],
@@ -309,6 +311,11 @@ const fleet = computed(() =>
}),
)
// Serials of aircraft connected right now — lets the Drones section flag which
// fleet entry is the drone in front of the pilot.
const connectedSerials = computed(() =>
ids.value.map((id) => (devices[id].connected ? devices[id].serial : '')).filter(Boolean),
)
const onlineCount = computed(() => ids.value.filter((id) => devices[id].online).length)
const flyingCount = computed(() =>
ids.value.filter((id) => devices[id].online && devices[id].connected).length,
@@ -381,10 +388,62 @@ const orgLabel = computed(
() => props.organizationName || (props.role === 'superadmin' ? 'All organizations' : 'No organization'),
)
/* ---------- fleet auto-registration ---------- */
// Connecting a drone in the Fly App should be enough to get it into the pilot's
// fleet — nobody wants to retype a serial off an airframe. Every device update
// carrying a connected aircraft's serial is offered to the server, which upserts
// on serial (see POST /api/drones/auto).
//
// upsert() runs on every telemetry frame, so the identity tuple is remembered
// and only a *change* is sent: without that this would fire a request per frame.
// The tuple (not just the serial) is the key because serial and the two firmware
// versions resolve on their own schedules after connect — a later event filling
// firmware in has to reach the server too.
const sentIdentities = new Set()
const dronesView = ref(null)
async function autoRegister(d) {
if (!d.connected || !d.serial) return
const identity = {
serial: d.serial,
model: d.model || '',
firmware: d.firmware || '',
controllerFirmware: d.controllerFirmware || '',
}
const key = [identity.serial, identity.model, identity.firmware, identity.controllerFirmware].join('|')
if (sentIdentities.has(key)) return
sentIdentities.add(key)
const res = await autoAddDrone(identity)
if (!res.ok) {
// Only a *transient* failure earns a retry: forget the key so the next event
// tries again. A 4xx is the server's settled answer (409 = another org's
// airframe, 401 = session gone, 400 = it dislikes this payload) and will not
// change on its own — since upsert() runs on every telemetry frame, retrying
// one would mean a request per frame for as long as the drone stays connected.
const transient = res.status === 0 || res.status >= 500
if (transient) sentIdentities.delete(key)
return
}
if (res.body?.created || res.body?.updated) dronesView.value?.reload()
}
// A drone deleted from the fleet must be able to come back: without this, its
// identity tuple stays in sentIdentities and every later event short-circuits,
// so a drone deleted while connected would not reappear until a page reload.
function forgetIdentity(serial) {
if (!serial) return
for (const key of sentIdentities) {
if (key.startsWith(`${serial}|`)) sentIdentities.delete(key)
}
}
/* ---------- realtime plumbing ---------- */
function upsert(d) {
devices[d.deviceId] = d
autoRegister(d)
const t = d.telemetry || {}
if (typeof t.latitude === 'number' && typeof t.longitude === 'number' && (t.latitude || t.longitude)) {
if (!trails[d.deviceId]) trails[d.deviceId] = []
@@ -986,6 +1045,10 @@ onBeforeUnmount(() => {
<!-- ---------- Logbook ---------- -->
<Logbook v-else-if="active === 'Logbook'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
<!-- ---------- Drones ---------- -->
<Drones v-else-if="active === 'Drones'" ref="dronesView" :connected-serials="connectedSerials"
@deleted="forgetIdentity" />
<!-- ---------- Documents ---------- -->
<Documents v-else-if="active === 'Documents'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
+278
View File
@@ -0,0 +1,278 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import Icon from './Icon.vue'
import { getDrones, createDrone, updateDrone, deleteDrone } from '../api.js'
// Org/role scoping is enforced server-side, so this component needs no identity
// of its own — only which aircraft are connected right now.
const props = defineProps({
// Serials of the aircraft currently connected to a device, so the fleet can
// show which entry is the drone in front of the pilot right now.
connectedSerials: { type: Array, default: () => [] },
})
// Deleting a drone has to reach the auto-registration bookkeeping in Dashboard,
// or a drone deleted while connected would never be auto-added again.
const emit = defineEmits(['deleted'])
const badgeClass = {
success: 'bg-success-soft text-success-fg',
accent: 'bg-accent-soft text-accent-soft-fg',
neutral: 'bg-surface-2 text-ink-secondary',
}
const drones = ref([])
const loading = ref(false)
const loadErr = ref('')
async function loadAll() {
loading.value = true
loadErr.value = ''
const dr = await getDrones()
if (!dr.ok) {
loadErr.value =
dr.status === 503
? 'Drone storage is not configured on the API Server (service account missing).'
: 'Could not load your drones.'
}
drones.value = dr.drones
loading.value = false
}
onMounted(loadAll)
defineExpose({ reload: loadAll })
const connected = computed(() => new Set(props.connectedSerials.filter(Boolean)))
function isConnected(d) {
return !!d.serial && connected.value.has(d.serial)
}
/* ---------------- drone form ---------------- */
const C_CLASSES = ['', 'C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6']
function blankDrone() {
return {
name: '', model: '', serial: '', firmware: '', controllerFirmware: '',
registration: '', operatorNumber: '', mtomGrams: '', isToy: false,
autologsFlights: false, cClass: '',
}
}
const showForm = ref(false)
const editingId = ref('')
const form = reactive(blankDrone())
const msg = ref('')
const saving = ref(false)
function newDrone() {
Object.assign(form, blankDrone())
editingId.value = ''
msg.value = ''
showForm.value = true
}
function editDrone(d) {
Object.assign(form, {
name: d.name || '', model: d.model || '', serial: d.serial || '',
firmware: d.firmware || '', controllerFirmware: d.controllerFirmware || '',
registration: d.registration || '', operatorNumber: d.operatorNumber || '',
mtomGrams: d.mtomGrams || '', isToy: !!d.isToy,
autologsFlights: !!d.autologsFlights, cClass: d.cClass || '',
})
editingId.value = d.id
msg.value = ''
showForm.value = true
}
function cancel() {
showForm.value = false
editingId.value = ''
}
async function save() {
msg.value = ''
if (!form.name.trim() && !form.model.trim() && !form.serial.trim()) {
msg.value = 'Give the drone a custom name, model or serial.'
return
}
saving.value = true
const payload = { ...form, mtomGrams: Number(form.mtomGrams) || 0 }
const res = editingId.value ? await updateDrone(editingId.value, payload) : await createDrone(payload)
saving.value = false
if (!res.ok) {
msg.value = res.body?.error || 'Could not save the drone.'
return
}
showForm.value = false
await loadAll()
}
const confirmId = ref('')
async function removeDrone(d) {
const res = await deleteDrone(d.id)
confirmId.value = ''
if (res.ok) {
emit('deleted', d.serial)
await loadAll()
} else msg.value = res.body?.error || 'Could not delete the drone.'
}
/* ---------------- headline stats ---------------- */
const stats = computed(() => ({
fleet: drones.value.length,
connected: drones.value.filter(isConnected).length,
unnamed: drones.value.filter((d) => !(d.name || '').trim()).length,
unregistered: drones.value.filter((d) => !(d.registration || '').trim()).length,
}))
</script>
<template>
<div class="mx-auto flex max-w-[1240px] flex-col gap-5 p-7">
<!-- header + actions -->
<div class="flex flex-wrap items-center gap-3">
<div>
<div class="eyebrow">Fleet</div>
<div class="mt-0.5 text-base font-semibold text-ink">Drones you fly</div>
</div>
<div class="ml-auto flex items-center gap-2">
<button class="btn-accent inline-flex items-center gap-2" @click="newDrone">
<Icon name="plus" :size="15" /> Add drone
</button>
</div>
</div>
<!-- stat row -->
<div class="grid grid-cols-4 gap-4 max-[900px]:grid-cols-2">
<div v-for="s in [
{ label: 'Drones in fleet', value: stats.fleet, tone: 'neutral' },
{ label: 'Connected now', value: stats.connected, tone: stats.connected ? 'success' : 'neutral' },
{ label: 'Awaiting a name', value: stats.unnamed, tone: 'neutral' },
{ label: 'No registration', value: stats.unregistered, tone: 'neutral' },
]" :key="s.label" class="panel p-5">
<div class="eyebrow">{{ s.label }}</div>
<div class="mt-2 text-[30px] font-bold leading-none tracking-tightest"
:class="s.tone === 'success' ? 'text-success-fg' : 'text-ink'">
{{ s.value }}
</div>
</div>
</div>
<div v-if="loadErr" class="panel border-danger/40 p-4 text-sm text-danger-fg">{{ loadErr }}</div>
<!-- add / edit form -->
<div v-if="showForm" class="panel p-5">
<div class="mb-4 flex items-center justify-between">
<div>
<div class="eyebrow">{{ editingId ? 'Edit drone' : 'New drone' }}</div>
<div class="mt-0.5 text-base font-semibold text-ink">Aircraft registry</div>
</div>
<button class="btn-icon" @click="cancel"><Icon name="x" :size="16" /></button>
</div>
<div class="grid grid-cols-3 gap-3 max-[760px]:grid-cols-1">
<label class="block"><span class="eyebrow mb-1 block">Custom name</span>
<input v-model="form.name" class="field" placeholder="Mavic-01" /></label>
<label class="block"><span class="eyebrow mb-1 block">Model</span>
<input v-model="form.model" class="field" placeholder="DJI Mavic 3 Enterprise" /></label>
<label class="block"><span class="eyebrow mb-1 block">Serial number</span>
<input v-model="form.serial" class="field" /></label>
<label class="block"><span class="eyebrow mb-1 block">Drone firmware</span>
<input v-model="form.firmware" class="field" placeholder="03.02.35.05" /></label>
<label class="block"><span class="eyebrow mb-1 block">Controller firmware</span>
<input v-model="form.controllerFirmware" class="field" placeholder="01.03.0800" /></label>
<label class="block"><span class="eyebrow mb-1 block">Registration (FAA/CAA)</span>
<input v-model="form.registration" class="field" placeholder="FA3X7K9PLM" /></label>
<label class="block"><span class="eyebrow mb-1 block">Operator no. (EU)</span>
<input v-model="form.operatorNumber" class="field" placeholder="DNK…" /></label>
<label class="block"><span class="eyebrow mb-1 block">MTOM (grams)</span>
<input v-model="form.mtomGrams" type="number" min="0" class="field" placeholder="920" /></label>
<label class="block"><span class="eyebrow mb-1 block">C-class</span>
<select v-model="form.cClass" class="field">
<option v-for="c in C_CLASSES" :key="c" :value="c">{{ c || '— none —' }}</option>
</select></label>
</div>
<p class="mt-3 text-xs text-ink-muted">
Model, serial and both firmware versions fill themselves in when the drone connects — anything
you type here is kept as-is.
</p>
<div class="mt-3 flex flex-wrap gap-6">
<label class="flex items-center gap-2 text-sm text-ink-secondary">
<input v-model="form.autologsFlights" type="checkbox" class="h-4 w-4 accent-[var(--accent)]" />
Auto-logs flights (onboard FDR)
</label>
<label class="flex items-center gap-2 text-sm text-ink-secondary">
<input v-model="form.isToy" type="checkbox" class="h-4 w-4 accent-[var(--accent)]" />
Toy drone (logbook-exempt)
</label>
</div>
<div class="mt-4 flex items-center gap-3">
<button class="btn-accent" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : editingId ? 'Save changes' : 'Add drone' }}
</button>
<button class="btn-ghost" @click="cancel">Cancel</button>
<span v-if="msg" class="text-sm text-danger-fg">{{ msg }}</span>
</div>
</div>
<!-- fleet table -->
<div class="panel overflow-hidden p-0">
<div v-if="loading" class="px-5 py-12 text-center text-sm text-ink-muted">Loading…</div>
<div v-else-if="!drones.length" class="grid place-items-center px-5 py-16 text-center">
<Icon name="drone" :size="26" class="text-ink-muted" />
<div class="mt-3 text-sm font-medium text-ink-secondary">No drones yet</div>
<div class="mt-1 text-xs text-ink-muted">
Connect a drone in the Fly App and it lands here by itself — or add one by hand.
</div>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="text-left">
<th v-for="h in ['Drone', 'Serial', 'Firmware', 'Ctrl FW', 'Registration', 'Class', '']" :key="h"
class="border-y border-line bg-surface-2 px-5 py-2.5 font-mono text-[10.5px] font-normal uppercase tracking-caps text-ink-muted">
{{ h }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="d in drones" :key="d.id" class="border-b border-line last:border-0"
:class="editingId === d.id ? 'bg-accent-soft' : ''">
<td class="px-5 py-3">
<div class="flex items-center gap-2">
<span class="font-semibold text-ink">{{ d.displayName }}</span>
<span v-if="isConnected(d)" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold"
:class="badgeClass.success">
<Icon name="signal" :size="11" /> connected
</span>
</div>
<div v-if="d.name && d.model" class="text-xs text-ink-muted">{{ d.model }}</div>
<div v-else-if="!d.name" class="text-xs text-ink-muted">no custom name yet</div>
</td>
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ d.serial || '—' }}</td>
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ d.firmware || '—' }}</td>
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ d.controllerFirmware || '—' }}</td>
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ d.registration || '—' }}</td>
<td class="px-5 py-3">
<span v-if="d.cClass" class="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold" :class="badgeClass.accent">{{ d.cClass }}</span>
<span v-else class="text-ink-muted">—</span>
<span v-if="d.isToy" class="ml-1 inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold" :class="badgeClass.neutral">toy</span>
</td>
<td class="whitespace-nowrap px-5 py-3 text-right">
<template v-if="confirmId === d.id">
<span class="mr-2 text-xs text-ink-muted">Delete?</span>
<button class="btn-ghost mr-1" @click="confirmId = ''">Cancel</button>
<button class="rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110" @click="removeDrone(d)">Delete</button>
</template>
<template v-else>
<button class="btn-ghost mr-1 inline-flex items-center gap-1" @click="editDrone(d)"><Icon name="sliders" :size="13" /> Edit</button>
<button class="btn-ghost inline-flex items-center gap-1" @click="confirmId = d.id"><Icon name="trash" :size="13" /></button>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
+168 -343
View File
@@ -1,10 +1,7 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import Icon from './Icon.vue'
import {
getDrones, createDrone, updateDrone, deleteDrone,
getFlights, createFlight, updateFlight, deleteFlight, exportLogbookUrl,
} from '../api.js'
import { getDrones, getFlights, createFlight, updateFlight, deleteFlight, exportLogbookUrl } from '../api.js'
const props = defineProps({
email: { type: String, default: '' },
@@ -13,23 +10,20 @@ const props = defineProps({
organizationName: { type: String, default: '' },
})
// Tones flightBadge() can return.
const badgeClass = {
success: 'bg-success-soft text-success-fg',
warning: 'bg-amber-soft text-amber-fg',
danger: 'bg-danger-soft text-danger-fg',
accent: 'bg-accent-soft text-accent-soft-fg',
neutral: 'bg-surface-2 text-ink-secondary',
}
const tab = ref('flights') // 'flights' | 'drones'
// The fleet itself is managed in the Drones section; it is loaded here only to
// resolve the flight form's drone picker and each entry's drone name.
const drones = ref([])
const flights = ref([])
const loading = ref(false)
const loadErr = ref('')
const droneById = computed(() => Object.fromEntries(drones.value.map((d) => [d.id, d])))
async function loadAll() {
loading.value = true
loadErr.value = ''
@@ -129,7 +123,7 @@ function cancelFlight() {
async function saveFlight() {
flightMsg.value = ''
if (!flightForm.drone) {
flightMsg.value = 'Select a drone first (add one on the Drones tab).'
flightMsg.value = 'Select a drone first (add one in the Drones section).'
return
}
savingFlight.value = true
@@ -153,75 +147,13 @@ async function removeFlight(f) {
if (res.ok) await loadAll()
}
/* ---------------- drone form ---------------- */
const C_CLASSES = ['', 'C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6']
function blankDrone() {
return {
name: '', model: '', serial: '', operatorNumber: '',
mtomGrams: '', isToy: false, autologsFlights: false, cClass: '',
}
}
const showDroneForm = ref(false)
const editingDroneId = ref('')
const droneForm = reactive(blankDrone())
const droneMsg = ref('')
const savingDrone = ref(false)
function newDrone() {
Object.assign(droneForm, blankDrone())
editingDroneId.value = ''
droneMsg.value = ''
showDroneForm.value = true
}
function editDrone(d) {
Object.assign(droneForm, {
name: d.name || '', model: d.model || '', serial: d.serial || '',
operatorNumber: d.operatorNumber || '', mtomGrams: d.mtomGrams || '',
isToy: !!d.isToy, autologsFlights: !!d.autologsFlights, cClass: d.cClass || '',
})
editingDroneId.value = d.id
droneMsg.value = ''
showDroneForm.value = true
}
function cancelDrone() {
showDroneForm.value = false
editingDroneId.value = ''
}
async function saveDrone() {
droneMsg.value = ''
if (!droneForm.name.trim()) {
droneMsg.value = 'Give the drone a name.'
return
}
savingDrone.value = true
const payload = { ...droneForm, mtomGrams: Number(droneForm.mtomGrams) || 0 }
const res = editingDroneId.value
? await updateDrone(editingDroneId.value, payload)
: await createDrone(payload)
savingDrone.value = false
if (!res.ok) {
droneMsg.value = res.body?.error || 'Could not save the drone.'
return
}
showDroneForm.value = false
await loadAll()
}
const confirmDroneId = ref('')
async function removeDrone(d) {
const res = await deleteDrone(d.id)
confirmDroneId.value = ''
if (res.ok) await loadAll()
else droneMsg.value = res.body?.error || 'Could not delete the drone.'
}
/* ---------------- headline stats ---------------- */
const stats = computed(() => {
const total = flights.value.length
const flagged = flights.value.filter((f) => (f.compliance?.redFlags || []).length).length
const required = flights.value.filter((f) => f.compliance?.required).length
return { total, flagged, required, fleet: drones.value.length }
return { total, flagged, required }
})
</script>
@@ -229,16 +161,9 @@ const stats = computed(() => {
<div class="mx-auto flex max-w-[1240px] flex-col gap-5 p-7">
<!-- header + actions -->
<div class="flex flex-wrap items-center gap-3">
<div class="inline-flex rounded-lg border border-line bg-surface-1 p-0.5">
<button
v-for="t in [['flights', 'Flights'], ['drones', 'Drones']]"
:key="t[0]"
class="rounded-md px-3.5 py-1.5 text-sm font-semibold transition"
:class="tab === t[0] ? 'bg-accent-soft text-accent-soft-fg' : 'text-ink-secondary hover:text-ink'"
@click="tab = t[0]"
>
{{ t[1] }}
</button>
<div>
<div class="eyebrow">Logbook</div>
<div class="mt-0.5 text-base font-semibold text-ink">Flights (BEK 1649 §5)</div>
</div>
<div class="ml-auto flex items-center gap-2">
<a
@@ -248,22 +173,18 @@ const stats = computed(() => {
>
<Icon name="download" :size="15" /> Export CSV
</a>
<button v-if="tab === 'flights'" class="btn-accent inline-flex items-center gap-2" @click="newFlight">
<button class="btn-accent inline-flex items-center gap-2" @click="newFlight">
<Icon name="plus" :size="15" /> Log flight
</button>
<button v-else class="btn-accent inline-flex items-center gap-2" @click="newDrone">
<Icon name="plus" :size="15" /> Add drone
</button>
</div>
</div>
<!-- stat row -->
<div class="grid grid-cols-4 gap-4 max-[900px]:grid-cols-2">
<div class="grid grid-cols-3 gap-4 max-[900px]:grid-cols-1">
<div v-for="s in [
{ label: 'Flights logged', value: stats.total, tone: 'neutral' },
{ label: 'Require logbook', value: stats.required, tone: 'neutral' },
{ label: 'Compliance flags', value: stats.flagged, tone: stats.flagged ? 'danger' : 'success' },
{ label: 'Registered drones', value: stats.fleet, tone: 'neutral' },
]" :key="s.label" class="panel p-5">
<div class="eyebrow">{{ s.label }}</div>
<div class="mt-2 text-[30px] font-bold leading-none tracking-tightest"
@@ -275,277 +196,181 @@ const stats = computed(() => {
<div v-if="loadErr" class="panel border-danger/40 p-4 text-sm text-danger-fg">{{ loadErr }}</div>
<!-- ============ FLIGHTS ============ -->
<template v-if="tab === 'flights'">
<!-- add / edit form -->
<div v-if="showFlightForm" class="panel p-5">
<div class="mb-4 flex items-center justify-between">
<div>
<div class="eyebrow">{{ editingFlightId ? 'Edit entry' : 'New entry' }}</div>
<div class="mt-0.5 text-base font-semibold text-ink">Logbook flight (BEK 1649 §5)</div>
</div>
<button class="btn-icon" @click="cancelFlight"><Icon name="x" :size="16" /></button>
<!-- add / edit form -->
<div v-if="showFlightForm" class="panel p-5">
<div class="mb-4 flex items-center justify-between">
<div>
<div class="eyebrow">{{ editingFlightId ? 'Edit entry' : 'New entry' }}</div>
<div class="mt-0.5 text-base font-semibold text-ink">Logbook flight (BEK 1649 §5)</div>
</div>
<button class="btn-icon" @click="cancelFlight"><Icon name="x" :size="16" /></button>
</div>
<div class="grid grid-cols-3 gap-3 max-[760px]:grid-cols-1">
<label class="block">
<span class="eyebrow mb-1 block">Date</span>
<input v-model="flightForm.operationDate" type="date" class="field" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Start</span>
<input v-model="flightForm.startTime" type="time" class="field" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">End</span>
<input v-model="flightForm.endTime" type="time" class="field" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Drone</span>
<select v-model="flightForm.drone" class="field">
<option v-if="!drones.length" value="">— add a drone first —</option>
<option v-for="d in drones" :key="d.id" :value="d.id">{{ d.name }}{{ d.model ? ` · ${d.model}` : '' }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Max altitude (m AGL)</span>
<input v-model="flightForm.maxAltitudeAgl" type="number" min="0" class="field" placeholder="120" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Area / route</span>
<input v-model="flightForm.areaRoute" class="field" placeholder="Field N of Roskilde, grid survey" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Remote pilot name</span>
<input v-model="flightForm.pilotName" class="field" placeholder="Full name" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Certificate ref</span>
<input v-model="flightForm.certificateRef" class="field" placeholder="A2 / STS cert no." />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Logging path</span>
<select v-model="flightForm.loggingPath" class="field">
<option v-for="p in PATHS" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Category</span>
<select v-model="flightForm.category" class="field">
<option v-for="c in CATEGORIES" :key="c.value" :value="c.value">{{ c.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Purpose</span>
<select v-model="flightForm.purpose" class="field">
<option v-for="p in PURPOSES" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Authorisation ref</span>
<input v-model="flightForm.authorisationRef" class="field" placeholder="Specific-category ref" />
</label>
</div>
<label class="mt-3 block">
<span class="eyebrow mb-1 block">FDR log URL (automatic path)</span>
<input v-model="flightForm.rawFdrLogUrl" class="field" placeholder="Link to the stored flight-data-recorder export" />
<div class="grid grid-cols-3 gap-3 max-[760px]:grid-cols-1">
<label class="block">
<span class="eyebrow mb-1 block">Date</span>
<input v-model="flightForm.operationDate" type="date" class="field" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Start</span>
<input v-model="flightForm.startTime" type="time" class="field" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">End</span>
<input v-model="flightForm.endTime" type="time" class="field" />
</label>
<button class="mt-4 flex items-center gap-1.5 text-sm font-semibold text-accent" @click="showDetails = !showDetails">
<Icon :name="showDetails ? 'x' : 'plus'" :size="14" /> Operational details (weather, airspace, incidents)
<label class="block">
<span class="eyebrow mb-1 block">Drone</span>
<select v-model="flightForm.drone" class="field">
<option v-if="!drones.length" value="">— add a drone first —</option>
<option v-for="d in drones" :key="d.id" :value="d.id">{{ d.displayName }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Max altitude (m AGL)</span>
<input v-model="flightForm.maxAltitudeAgl" type="number" min="0" class="field" placeholder="120" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Area / route</span>
<input v-model="flightForm.areaRoute" class="field" placeholder="Field N of Roskilde, grid survey" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Remote pilot name</span>
<input v-model="flightForm.pilotName" class="field" placeholder="Full name" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Certificate ref</span>
<input v-model="flightForm.certificateRef" class="field" placeholder="A2 / STS cert no." />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Logging path</span>
<select v-model="flightForm.loggingPath" class="field">
<option v-for="p in PATHS" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Category</span>
<select v-model="flightForm.category" class="field">
<option v-for="c in CATEGORIES" :key="c.value" :value="c.value">{{ c.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Purpose</span>
<select v-model="flightForm.purpose" class="field">
<option v-for="p in PURPOSES" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Authorisation ref</span>
<input v-model="flightForm.authorisationRef" class="field" placeholder="Specific-category ref" />
</label>
</div>
<label class="mt-3 block">
<span class="eyebrow mb-1 block">FDR log URL (automatic path)</span>
<input v-model="flightForm.rawFdrLogUrl" class="field" placeholder="Link to the stored flight-data-recorder export" />
</label>
<button class="mt-4 flex items-center gap-1.5 text-sm font-semibold text-accent" @click="showDetails = !showDetails">
<Icon :name="showDetails ? 'x' : 'plus'" :size="14" /> Operational details (weather, airspace, incidents)
</button>
<div v-if="showDetails" class="mt-3 grid grid-cols-2 gap-3 max-[760px]:grid-cols-1">
<label class="block"><span class="eyebrow mb-1 block">Weather / wind</span>
<input v-model="flightForm.weather" class="field" placeholder="6 m/s NW, CAVOK" /></label>
<label class="block"><span class="eyebrow mb-1 block">Airspace / NOTAM ref</span>
<input v-model="flightForm.airspaceRef" class="field" /></label>
<label class="block"><span class="eyebrow mb-1 block">Observer</span>
<input v-model="flightForm.observer" class="field" /></label>
<label class="block"><span class="eyebrow mb-1 block">Incidents / anomalies</span>
<input v-model="flightForm.incidents" class="field" placeholder="RTH trigger, GPS dropout…" /></label>
<label class="col-span-2 block max-[760px]:col-span-1"><span class="eyebrow mb-1 block">Notes</span>
<textarea v-model="flightForm.notes" rows="2" class="field"></textarea></label>
</div>
<div class="mt-4 flex items-center gap-3">
<button class="btn-accent" :disabled="savingFlight" @click="saveFlight">
{{ savingFlight ? 'Saving…' : editingFlightId ? 'Save changes' : 'Log flight' }}
</button>
<div v-if="showDetails" class="mt-3 grid grid-cols-2 gap-3 max-[760px]:grid-cols-1">
<label class="block"><span class="eyebrow mb-1 block">Weather / wind</span>
<input v-model="flightForm.weather" class="field" placeholder="6 m/s NW, CAVOK" /></label>
<label class="block"><span class="eyebrow mb-1 block">Airspace / NOTAM ref</span>
<input v-model="flightForm.airspaceRef" class="field" /></label>
<label class="block"><span class="eyebrow mb-1 block">Observer</span>
<input v-model="flightForm.observer" class="field" /></label>
<label class="block"><span class="eyebrow mb-1 block">Incidents / anomalies</span>
<input v-model="flightForm.incidents" class="field" placeholder="RTH trigger, GPS dropout…" /></label>
<label class="col-span-2 block max-[760px]:col-span-1"><span class="eyebrow mb-1 block">Notes</span>
<textarea v-model="flightForm.notes" rows="2" class="field"></textarea></label>
</div>
<div class="mt-4 flex items-center gap-3">
<button class="btn-accent" :disabled="savingFlight" @click="saveFlight">
{{ savingFlight ? 'Saving…' : editingFlightId ? 'Save changes' : 'Log flight' }}
</button>
<button class="btn-ghost" @click="cancelFlight">Cancel</button>
<span v-if="flightMsg" class="text-sm text-danger-fg">{{ flightMsg }}</span>
</div>
<button class="btn-ghost" @click="cancelFlight">Cancel</button>
<span v-if="flightMsg" class="text-sm text-danger-fg">{{ flightMsg }}</span>
</div>
</div>
<!-- flights table -->
<div class="panel overflow-hidden p-0">
<div v-if="loading" class="px-5 py-12 text-center text-sm text-ink-muted">Loading…</div>
<div v-else-if="!flights.length" class="grid place-items-center px-5 py-16 text-center">
<Icon name="book" :size="26" class="text-ink-muted" />
<div class="mt-3 text-sm font-medium text-ink-secondary">No flights logged yet</div>
<div class="mt-1 text-xs text-ink-muted">Log your first operation to start the 5-year retention record.</div>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="text-left">
<th v-for="h in ['Date', 'Drone', 'Area / route', 'Alt', 'Pilot', 'Compliance', '']" :key="h"
class="border-y border-line bg-surface-2 px-5 py-2.5 font-mono text-[10.5px] font-normal uppercase tracking-caps text-ink-muted">
{{ h }}
</th>
</tr>
</thead>
<tbody>
<template v-for="f in flights" :key="f.id">
<tr class="border-b border-line last:border-0" :class="editingFlightId === f.id ? 'bg-accent-soft' : ''">
<td class="whitespace-nowrap px-5 py-3 font-mono text-ink">
{{ (f.operationDate || '').slice(0, 10) }}
<span v-if="f.startTime" class="text-ink-muted">{{ f.startTime }}</span>
</td>
<td class="px-5 py-3 text-ink-secondary">{{ f.droneName || '—' }}</td>
<td class="max-w-[220px] truncate px-5 py-3 text-ink-secondary" :title="f.areaRoute">{{ f.areaRoute || '—' }}</td>
<td class="px-5 py-3 font-mono text-ink-secondary">{{ f.maxAltitudeAgl ? f.maxAltitudeAgl + ' m' : '—' }}</td>
<td class="px-5 py-3 text-ink-secondary">{{ f.pilotName || '—' }}</td>
<td class="px-5 py-3">
<button
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="badgeClass[flightBadge(f).tone]"
@click="toggleRow(f.id)"
>
<Icon v-if="flightBadge(f).tone === 'danger'" name="alertTriangle" :size="12" />
<Icon v-else-if="flightBadge(f).tone === 'success'" name="check" :size="12" />
{{ flightBadge(f).label }}
</button>
</td>
<td class="whitespace-nowrap px-5 py-3 text-right">
<template v-if="confirmFlightId === f.id">
<span class="mr-2 text-xs text-ink-muted">Delete?</span>
<button class="btn-ghost mr-1" @click="confirmFlightId = ''">Cancel</button>
<button class="rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110" @click="removeFlight(f)">Delete</button>
</template>
<template v-else>
<button class="btn-ghost mr-1 inline-flex items-center gap-1" @click="editFlight(f)"><Icon name="sliders" :size="13" /> Edit</button>
<button class="btn-ghost inline-flex items-center gap-1" @click="confirmFlightId = f.id"><Icon name="trash" :size="13" /></button>
</template>
</td>
</tr>
<tr v-if="openRow === f.id" class="border-b border-line bg-surface-2">
<td colspan="7" class="px-5 py-3">
<div class="flex flex-wrap gap-x-8 gap-y-1.5 text-xs">
<span class="text-ink-secondary">Logging path: <b class="text-ink">{{ f.compliance?.loggingPath || '—' }}</b></span>
<span class="text-ink-secondary">Category: <b class="text-ink">{{ f.category || '—' }}</b></span>
<span class="text-ink-secondary">Retain until: <b class="font-mono text-ink">{{ (f.retentionUntil || '').slice(0, 10) || '—' }}</b></span>
<span v-if="f.compliance?.exempt" class="text-ink-secondary">Exempt: <b class="text-ink">{{ f.compliance.exemptReason }}</b></span>
</div>
<ul v-if="(f.compliance?.redFlags || []).length" class="mt-2 space-y-1">
<li v-for="(rf, i) in f.compliance.redFlags" :key="i" class="flex items-start gap-2 text-xs text-danger-fg">
<Icon name="alertTriangle" :size="13" class="mt-px shrink-0" /> {{ rf }}
</li>
</ul>
<div v-else-if="!f.compliance?.exempt" class="mt-2 text-xs text-success-fg">No compliance gaps detected.</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- flights table -->
<div class="panel overflow-hidden p-0">
<div v-if="loading" class="px-5 py-12 text-center text-sm text-ink-muted">Loading…</div>
<div v-else-if="!flights.length" class="grid place-items-center px-5 py-16 text-center">
<Icon name="book" :size="26" class="text-ink-muted" />
<div class="mt-3 text-sm font-medium text-ink-secondary">No flights logged yet</div>
<div class="mt-1 text-xs text-ink-muted">Log your first operation to start the 5-year retention record.</div>
</div>
</template>
<!-- ============ DRONES ============ -->
<template v-else>
<div v-if="showDroneForm" class="panel p-5">
<div class="mb-4 flex items-center justify-between">
<div>
<div class="eyebrow">{{ editingDroneId ? 'Edit drone' : 'New drone' }}</div>
<div class="mt-0.5 text-base font-semibold text-ink">Aircraft registry</div>
</div>
<button class="btn-icon" @click="cancelDrone"><Icon name="x" :size="16" /></button>
</div>
<div class="grid grid-cols-3 gap-3 max-[760px]:grid-cols-1">
<label class="block"><span class="eyebrow mb-1 block">Name</span>
<input v-model="droneForm.name" class="field" placeholder="Mavic-01" /></label>
<label class="block"><span class="eyebrow mb-1 block">Model</span>
<input v-model="droneForm.model" class="field" placeholder="DJI Mavic 3 Enterprise" /></label>
<label class="block"><span class="eyebrow mb-1 block">Serial</span>
<input v-model="droneForm.serial" class="field" /></label>
<label class="block"><span class="eyebrow mb-1 block">Operator no.</span>
<input v-model="droneForm.operatorNumber" class="field" placeholder="DNK…" /></label>
<label class="block"><span class="eyebrow mb-1 block">MTOM (grams)</span>
<input v-model="droneForm.mtomGrams" type="number" min="0" class="field" placeholder="920" /></label>
<label class="block"><span class="eyebrow mb-1 block">C-class</span>
<select v-model="droneForm.cClass" class="field">
<option v-for="c in C_CLASSES" :key="c" :value="c">{{ c || '— none —' }}</option>
</select></label>
</div>
<div class="mt-3 flex flex-wrap gap-6">
<label class="flex items-center gap-2 text-sm text-ink-secondary">
<input v-model="droneForm.autologsFlights" type="checkbox" class="h-4 w-4 accent-[var(--accent)]" />
Auto-logs flights (onboard FDR)
</label>
<label class="flex items-center gap-2 text-sm text-ink-secondary">
<input v-model="droneForm.isToy" type="checkbox" class="h-4 w-4 accent-[var(--accent)]" />
Toy drone (logbook-exempt)
</label>
</div>
<div class="mt-4 flex items-center gap-3">
<button class="btn-accent" :disabled="savingDrone" @click="saveDrone">
{{ savingDrone ? 'Saving…' : editingDroneId ? 'Save changes' : 'Add drone' }}
</button>
<button class="btn-ghost" @click="cancelDrone">Cancel</button>
<span v-if="droneMsg" class="text-sm text-danger-fg">{{ droneMsg }}</span>
</div>
</div>
<div class="panel overflow-hidden p-0">
<div v-if="loading" class="px-5 py-12 text-center text-sm text-ink-muted">Loading…</div>
<div v-else-if="!drones.length" class="grid place-items-center px-5 py-16 text-center">
<Icon name="drone" :size="26" class="text-ink-muted" />
<div class="mt-3 text-sm font-medium text-ink-secondary">No drones registered</div>
<div class="mt-1 text-xs text-ink-muted">Register the airframes you fly to log flights against them.</div>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="text-left">
<th v-for="h in ['Name', 'Model', 'MTOM', 'Class', 'FDR', '']" :key="h"
class="border-y border-line bg-surface-2 px-5 py-2.5 font-mono text-[10.5px] font-normal uppercase tracking-caps text-ink-muted">
{{ h }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="d in drones" :key="d.id" class="border-b border-line last:border-0" :class="editingDroneId === d.id ? 'bg-accent-soft' : ''">
<td class="px-5 py-3 font-semibold text-ink">{{ d.name }}</td>
<td class="px-5 py-3 text-ink-secondary">{{ d.model || '—' }}</td>
<td class="px-5 py-3 font-mono text-ink-secondary">{{ d.mtomGrams ? d.mtomGrams + ' g' : '—' }}</td>
<td class="px-5 py-3">
<span v-if="d.cClass" class="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold" :class="badgeClass.accent">{{ d.cClass }}</span>
<span v-else class="text-ink-muted">—</span>
<span v-if="d.isToy" class="ml-1 inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold" :class="badgeClass.neutral">toy</span>
<div v-else class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="text-left">
<th v-for="h in ['Date', 'Drone', 'Area / route', 'Alt', 'Pilot', 'Compliance', '']" :key="h"
class="border-y border-line bg-surface-2 px-5 py-2.5 font-mono text-[10.5px] font-normal uppercase tracking-caps text-ink-muted">
{{ h }}
</th>
</tr>
</thead>
<tbody>
<template v-for="f in flights" :key="f.id">
<tr class="border-b border-line last:border-0" :class="editingFlightId === f.id ? 'bg-accent-soft' : ''">
<td class="whitespace-nowrap px-5 py-3 font-mono text-ink">
{{ (f.operationDate || '').slice(0, 10) }}
<span v-if="f.startTime" class="text-ink-muted">{{ f.startTime }}</span>
</td>
<td class="px-5 py-3 text-ink-secondary">{{ f.droneName || '—' }}</td>
<td class="max-w-[220px] truncate px-5 py-3 text-ink-secondary" :title="f.areaRoute">{{ f.areaRoute || '—' }}</td>
<td class="px-5 py-3 font-mono text-ink-secondary">{{ f.maxAltitudeAgl ? f.maxAltitudeAgl + ' m' : '—' }}</td>
<td class="px-5 py-3 text-ink-secondary">{{ f.pilotName || '—' }}</td>
<td class="px-5 py-3">
<span class="text-xs" :class="d.autologsFlights ? 'text-success-fg' : 'text-ink-muted'">{{ d.autologsFlights ? 'yes' : 'no' }}</span>
<button
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="badgeClass[flightBadge(f).tone]"
@click="toggleRow(f.id)"
>
<Icon v-if="flightBadge(f).tone === 'danger'" name="alertTriangle" :size="12" />
<Icon v-else-if="flightBadge(f).tone === 'success'" name="check" :size="12" />
{{ flightBadge(f).label }}
</button>
</td>
<td class="whitespace-nowrap px-5 py-3 text-right">
<template v-if="confirmDroneId === d.id">
<template v-if="confirmFlightId === f.id">
<span class="mr-2 text-xs text-ink-muted">Delete?</span>
<button class="btn-ghost mr-1" @click="confirmDroneId = ''">Cancel</button>
<button class="rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110" @click="removeDrone(d)">Delete</button>
<button class="btn-ghost mr-1" @click="confirmFlightId = ''">Cancel</button>
<button class="rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110" @click="removeFlight(f)">Delete</button>
</template>
<template v-else>
<button class="btn-ghost mr-1 inline-flex items-center gap-1" @click="editDrone(d)"><Icon name="sliders" :size="13" /> Edit</button>
<button class="btn-ghost inline-flex items-center gap-1" @click="confirmDroneId = d.id"><Icon name="trash" :size="13" /></button>
<button class="btn-ghost mr-1 inline-flex items-center gap-1" @click="editFlight(f)"><Icon name="sliders" :size="13" /> Edit</button>
<button class="btn-ghost inline-flex items-center gap-1" @click="confirmFlightId = f.id"><Icon name="trash" :size="13" /></button>
</template>
</td>
</tr>
</tbody>
</table>
</div>
<tr v-if="openRow === f.id" class="border-b border-line bg-surface-2">
<td colspan="7" class="px-5 py-3">
<div class="flex flex-wrap gap-x-8 gap-y-1.5 text-xs">
<span class="text-ink-secondary">Logging path: <b class="text-ink">{{ f.compliance?.loggingPath || '—' }}</b></span>
<span class="text-ink-secondary">Category: <b class="text-ink">{{ f.category || '—' }}</b></span>
<span class="text-ink-secondary">Retain until: <b class="font-mono text-ink">{{ (f.retentionUntil || '').slice(0, 10) || '—' }}</b></span>
<span v-if="f.compliance?.exempt" class="text-ink-secondary">Exempt: <b class="text-ink">{{ f.compliance.exemptReason }}</b></span>
</div>
<ul v-if="(f.compliance?.redFlags || []).length" class="mt-2 space-y-1">
<li v-for="(rf, i) in f.compliance.redFlags" :key="i" class="flex items-start gap-2 text-xs text-danger-fg">
<Icon name="alertTriangle" :size="13" class="mt-px shrink-0" /> {{ rf }}
</li>
</ul>
<div v-else-if="!f.compliance?.exempt" class="mt-2 text-xs text-success-fg">No compliance gaps detected.</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
</div>
</template>