Add compliance & operational document store (PocketBase)
Introduces a `documents` collection and full-stack UI for tracking pilot certificates, aircraft registrations, insurance, airspace authorisations, contracts, and other paperwork. - Migration 1720300800_add_documents.js (also provisioned live on remote PB): doc_type/owner/expiry/status/access_tier + file blob, a self-referential `replaces` version chain, audit fields, and a partial index on expiry_date. - API Server (documents.go): role-scoped CRUD, server-computed expiry assessment, ?expiring=N query, versioning (replaces -> version+1, old row auto-archived), and streamed blob download. admin.go gains multipart upload, file tokens, and protected-file streaming. - Web App: BFF passthrough (multipart create + streamed download), api.js client fns, and Documents.vue wired into the Documents nav slot. Blobs live in PocketBase file storage for now; only that backend swaps when S3-compatible object storage lands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e3106d7b60
commit
52f84ad6cf
@@ -5,6 +5,7 @@ import BrandMark from './BrandMark.vue'
|
||||
import Icon from './Icon.vue'
|
||||
import Settings from './Settings.vue'
|
||||
import Logbook from './Logbook.vue'
|
||||
import Documents from './Documents.vue'
|
||||
import { getDevices, sendCommand } from '../api.js'
|
||||
import { formatTime } from '../prefs.js'
|
||||
|
||||
@@ -631,6 +632,9 @@ onBeforeUnmount(() => {
|
||||
<!-- ---------- Logbook ---------- -->
|
||||
<Logbook v-else-if="active === 'Logbook'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
|
||||
|
||||
<!-- ---------- Documents ---------- -->
|
||||
<Documents v-else-if="active === 'Documents'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
|
||||
|
||||
<!-- ---------- Settings ---------- -->
|
||||
<Settings v-else-if="active === 'Settings'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" @logout="emit('logout')" />
|
||||
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import {
|
||||
getDocuments, createDocument, updateDocument, deleteDocument, documentFileUrl,
|
||||
getDrones,
|
||||
} from '../api.js'
|
||||
|
||||
const props = defineProps({
|
||||
email: { type: String, default: '' },
|
||||
role: { type: String, default: 'user' },
|
||||
organization: { type: String, default: '' },
|
||||
organizationName: { type: String, default: '' },
|
||||
})
|
||||
|
||||
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 DOC_TYPES = [
|
||||
{ value: 'certificate', label: 'Pilot certificate' },
|
||||
{ value: 'medical', label: 'Medical / training' },
|
||||
{ value: 'insurance', label: 'Insurance / liability' },
|
||||
{ value: 'background_check', label: 'Background check / waiver' },
|
||||
{ value: 'registration', label: 'Aircraft registration' },
|
||||
{ value: 'maintenance', label: 'Maintenance log' },
|
||||
{ value: 'conformity', label: 'Conformity / compliance' },
|
||||
{ value: 'firmware', label: 'Firmware / software' },
|
||||
{ value: 'incident', label: 'Incident / repair report' },
|
||||
{ value: 'flight_log', label: 'Flight log' },
|
||||
{ value: 'checklist', label: 'Pre-flight checklist' },
|
||||
{ value: 'airspace_auth', label: 'Airspace authorisation' },
|
||||
{ value: 'mission_plan', label: 'Mission plan / flight path' },
|
||||
{ value: 'risk_assessment', label: 'Risk assessment / survey' },
|
||||
{ value: 'contract', label: 'Contract / SOW' },
|
||||
{ value: 'client_insurance', label: 'Client insurance cert' },
|
||||
{ value: 'delivery_report', label: 'Delivery / media handoff' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
const DOC_TYPE_LABEL = Object.fromEntries(DOC_TYPES.map((t) => [t.value, t.label]))
|
||||
const OWNER_TYPES = [
|
||||
{ value: 'pilot', label: 'Pilot' },
|
||||
{ value: 'aircraft', label: 'Aircraft' },
|
||||
{ value: 'organization', label: 'Organization' },
|
||||
{ value: 'client', label: 'Client' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
const STATUSES = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'pending_review', label: 'Pending review' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
]
|
||||
const ACCESS_TIERS = [
|
||||
{ value: 'pilot', label: 'Pilot' },
|
||||
{ value: 'ops', label: 'Ops manager' },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'client', label: 'Client-facing' },
|
||||
]
|
||||
|
||||
const documents = ref([])
|
||||
const drones = ref([])
|
||||
const loading = ref(false)
|
||||
const loadErr = ref('')
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
loadErr.value = ''
|
||||
const [dc, dr] = await Promise.all([getDocuments(), getDrones()])
|
||||
if (!dc.ok) {
|
||||
loadErr.value =
|
||||
dc.status === 503
|
||||
? 'Document storage is not configured on the API Server (service account missing).'
|
||||
: 'Could not load documents.'
|
||||
}
|
||||
documents.value = dc.documents
|
||||
drones.value = dr.drones || []
|
||||
loading.value = false
|
||||
}
|
||||
onMounted(loadAll)
|
||||
|
||||
/* ---------------- filtering ---------------- */
|
||||
|
||||
const filter = ref('all') // all | expiring | expired | pending | archived
|
||||
const FILTERS = [
|
||||
['all', 'All'],
|
||||
['expiring', 'Expiring soon'],
|
||||
['expired', 'Expired'],
|
||||
['pending', 'Pending review'],
|
||||
['archived', 'Archived'],
|
||||
]
|
||||
|
||||
const visible = computed(() => {
|
||||
const all = documents.value
|
||||
switch (filter.value) {
|
||||
case 'expiring':
|
||||
return all.filter((d) => d.expiry?.state === 'expiring_soon' && d.status !== 'archived')
|
||||
case 'expired':
|
||||
return all.filter((d) => d.expiry?.state === 'expired' && d.status !== 'archived')
|
||||
case 'pending':
|
||||
return all.filter((d) => d.status === 'pending_review')
|
||||
case 'archived':
|
||||
return all.filter((d) => d.status === 'archived')
|
||||
default:
|
||||
return all.filter((d) => d.status !== 'archived')
|
||||
}
|
||||
})
|
||||
|
||||
/* ---------------- expiry presentation ---------------- */
|
||||
|
||||
function expiryBadge(d) {
|
||||
if (d.status === 'archived') return { tone: 'neutral', label: 'Superseded', icon: '' }
|
||||
const e = d.expiry || {}
|
||||
if (e.state === 'expired') return { tone: 'danger', label: 'Expired', icon: 'alertTriangle' }
|
||||
if (e.state === 'expiring_soon')
|
||||
return { tone: 'warning', label: `Expires in ${e.daysUntilExpiry}d`, icon: 'clock' }
|
||||
if (e.state === 'valid') return { tone: 'success', label: 'Valid', icon: 'check' }
|
||||
return { tone: 'neutral', label: 'No expiry', icon: '' }
|
||||
}
|
||||
|
||||
const openRow = ref('')
|
||||
function toggleRow(id) {
|
||||
openRow.value = openRow.value === id ? '' : id
|
||||
}
|
||||
|
||||
function ownerLabel(d) {
|
||||
if (d.ownerDrone) return d.ownerDroneName || 'Aircraft'
|
||||
if (d.ownerRef) return d.ownerRef
|
||||
if (d.ownerType === 'pilot') return 'Pilot'
|
||||
if (d.ownerType) return d.ownerType.charAt(0).toUpperCase() + d.ownerType.slice(1)
|
||||
return '—'
|
||||
}
|
||||
|
||||
/* ---------------- document form ---------------- */
|
||||
|
||||
function blankDoc() {
|
||||
return {
|
||||
title: '', docType: 'certificate', ownerType: 'pilot', ownerDrone: '',
|
||||
ownerRef: '', reference: '', jurisdiction: '', issueDate: '', expiryDate: '',
|
||||
status: 'active', accessTier: 'ops', notes: '',
|
||||
}
|
||||
}
|
||||
const showForm = ref(false)
|
||||
const editingId = ref('')
|
||||
const replacesId = ref('') // set when uploading a new version of an existing doc
|
||||
const replacesTitle = ref('')
|
||||
const form = reactive(blankDoc())
|
||||
const file = ref(null)
|
||||
const fileInput = ref(null)
|
||||
const formMsg = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
function resetFileInput() {
|
||||
file.value = null
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
function newDoc() {
|
||||
Object.assign(form, blankDoc())
|
||||
editingId.value = ''
|
||||
replacesId.value = ''
|
||||
replacesTitle.value = ''
|
||||
resetFileInput()
|
||||
formMsg.value = ''
|
||||
showForm.value = true
|
||||
}
|
||||
function editDoc(d) {
|
||||
Object.assign(form, {
|
||||
title: d.title || '', docType: d.docType || 'certificate',
|
||||
ownerType: d.ownerType || 'pilot', ownerDrone: d.ownerDrone || '',
|
||||
ownerRef: d.ownerRef || '', reference: d.reference || '',
|
||||
jurisdiction: d.jurisdiction || '', issueDate: d.issueDate || '',
|
||||
expiryDate: d.expiryDate || '', status: d.status || 'active',
|
||||
accessTier: d.accessTier || 'ops', notes: d.notes || '',
|
||||
})
|
||||
editingId.value = d.id
|
||||
replacesId.value = ''
|
||||
replacesTitle.value = ''
|
||||
resetFileInput()
|
||||
formMsg.value = ''
|
||||
showForm.value = true
|
||||
}
|
||||
// Upload a superseding version: pre-fill from the current doc, require a file.
|
||||
function newVersion(d) {
|
||||
editDoc(d)
|
||||
editingId.value = ''
|
||||
replacesId.value = d.id
|
||||
replacesTitle.value = d.title
|
||||
form.status = 'active'
|
||||
}
|
||||
function cancelForm() {
|
||||
showForm.value = false
|
||||
editingId.value = ''
|
||||
replacesId.value = ''
|
||||
}
|
||||
function onFile(e) {
|
||||
file.value = e.target.files?.[0] || null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
formMsg.value = ''
|
||||
if (!form.title.trim()) {
|
||||
formMsg.value = 'Give the document a title.'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
let res
|
||||
if (editingId.value) {
|
||||
// Metadata-only update (a new blob is a new version, not an overwrite).
|
||||
res = await updateDocument(editingId.value, { ...form })
|
||||
} else {
|
||||
const fields = { ...form }
|
||||
if (replacesId.value) fields.replaces = replacesId.value
|
||||
res = await createDocument(fields, file.value)
|
||||
}
|
||||
saving.value = false
|
||||
if (!res.ok) {
|
||||
formMsg.value = res.body?.error || 'Could not save the document.'
|
||||
return
|
||||
}
|
||||
showForm.value = false
|
||||
editingId.value = ''
|
||||
replacesId.value = ''
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
const confirmId = ref('')
|
||||
async function removeDoc(d) {
|
||||
const res = await deleteDocument(d.id)
|
||||
confirmId.value = ''
|
||||
if (res.ok) await loadAll()
|
||||
else formMsg.value = res.body?.error || 'Could not delete the document.'
|
||||
}
|
||||
|
||||
/* ---------------- headline stats ---------------- */
|
||||
|
||||
const stats = computed(() => {
|
||||
const live = documents.value.filter((d) => d.status !== 'archived')
|
||||
return {
|
||||
total: live.length,
|
||||
expiring: live.filter((d) => d.expiry?.state === 'expiring_soon').length,
|
||||
expired: live.filter((d) => d.expiry?.state === 'expired').length,
|
||||
pending: documents.value.filter((d) => d.status === 'pending_review').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 class="inline-flex flex-wrap rounded-lg border border-line bg-surface-1 p-0.5">
|
||||
<button
|
||||
v-for="f in FILTERS"
|
||||
:key="f[0]"
|
||||
class="rounded-md px-3.5 py-1.5 text-sm font-semibold transition"
|
||||
:class="filter === f[0] ? 'bg-accent-soft text-accent-soft-fg' : 'text-ink-secondary hover:text-ink'"
|
||||
@click="filter = f[0]"
|
||||
>
|
||||
{{ f[1] }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<button class="btn-accent inline-flex items-center gap-2" @click="newDoc">
|
||||
<Icon name="upload" :size="15" /> Add document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- stat row -->
|
||||
<div class="grid grid-cols-4 gap-4 max-[900px]:grid-cols-2">
|
||||
<div v-for="s in [
|
||||
{ label: 'Documents on file', value: stats.total, tone: 'neutral' },
|
||||
{ label: 'Expiring soon', value: stats.expiring, tone: stats.expiring ? 'warning' : 'neutral' },
|
||||
{ label: 'Expired', value: stats.expired, tone: stats.expired ? 'danger' : 'success' },
|
||||
{ label: 'Pending review', value: stats.pending, tone: stats.pending ? 'accent' : '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 === 'danger' ? 'text-danger-fg' : s.tone === 'warning' ? 'text-amber-fg' : s.tone === 'success' ? 'text-success-fg' : s.tone === 'accent' ? 'text-accent-soft-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 document' : replacesId ? 'New version' : 'New document' }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-base font-semibold text-ink">
|
||||
{{ replacesId ? `Supersedes “${replacesTitle}”` : 'Compliance & operational document' }}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon" @click="cancelForm"><Icon name="x" :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3 max-[760px]:grid-cols-1">
|
||||
<label class="col-span-2 block max-[760px]:col-span-1">
|
||||
<span class="eyebrow mb-1 block">Title</span>
|
||||
<input v-model="form.title" class="field" placeholder="A2 Remote Pilot Certificate — J. Dariusz" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="eyebrow mb-1 block">Type</span>
|
||||
<select v-model="form.docType" class="field">
|
||||
<option v-for="t in DOC_TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="eyebrow mb-1 block">Owner type</span>
|
||||
<select v-model="form.ownerType" class="field">
|
||||
<option v-for="o in OWNER_TYPES" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="eyebrow mb-1 block">Aircraft (if any)</span>
|
||||
<select v-model="form.ownerDrone" class="field">
|
||||
<option value="">— none —</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">Owner reference</span>
|
||||
<input v-model="form.ownerRef" class="field" placeholder="Client name / serial / site" />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="eyebrow mb-1 block">Reference / number</span>
|
||||
<input v-model="form.reference" class="field" placeholder="Cert / registration / policy no." />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="eyebrow mb-1 block">Jurisdiction</span>
|
||||
<input v-model="form.jurisdiction" class="field" placeholder="DK / EASA / FAA" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="eyebrow mb-1 block">Access tier</span>
|
||||
<select v-model="form.accessTier" class="field">
|
||||
<option v-for="a in ACCESS_TIERS" :key="a.value" :value="a.value">{{ a.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="eyebrow mb-1 block">Issue date</span>
|
||||
<input v-model="form.issueDate" type="date" class="field" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="eyebrow mb-1 block">Expiry date</span>
|
||||
<input v-model="form.expiryDate" type="date" class="field" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="eyebrow mb-1 block">Status</span>
|
||||
<select v-model="form.status" class="field">
|
||||
<option v-for="st in STATUSES" :key="st.value" :value="st.value">{{ st.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="mt-3 block">
|
||||
<span class="eyebrow mb-1 block">Notes</span>
|
||||
<textarea v-model="form.notes" rows="2" class="field" placeholder="Conditions, renewal contacts, anything worth recording"></textarea>
|
||||
</label>
|
||||
|
||||
<!-- file: only on create / new version; editing is metadata-only -->
|
||||
<div v-if="!editingId" class="mt-3">
|
||||
<span class="eyebrow mb-1 block">File {{ replacesId ? '(new version)' : '(optional)' }}</span>
|
||||
<input ref="fileInput" type="file" class="field" @change="onFile" />
|
||||
<p class="mt-1 text-xs text-ink-muted">
|
||||
Stored in PocketBase for now (object storage later). Max 50 MB.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="mt-3 text-xs text-ink-muted">
|
||||
Editing updates metadata only. To replace the file, close this and use
|
||||
<b class="text-ink-secondary">New version</b> on the document — the old version is kept for audit.
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<button class="btn-accent" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving…' : editingId ? 'Save changes' : replacesId ? 'Upload new version' : 'Add document' }}
|
||||
</button>
|
||||
<button class="btn-ghost" @click="cancelForm">Cancel</button>
|
||||
<span v-if="formMsg" class="text-sm text-danger-fg">{{ formMsg }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- documents 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="!visible.length" class="grid place-items-center px-5 py-16 text-center">
|
||||
<Icon name="fileText" :size="26" class="text-ink-muted" />
|
||||
<div class="mt-3 text-sm font-medium text-ink-secondary">
|
||||
{{ filter === 'all' ? 'No documents on file yet' : 'Nothing in this view' }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-ink-muted">
|
||||
{{ filter === 'all'
|
||||
? 'Add certificates, registrations, insurance and authorisations to track their expiry.'
|
||||
: 'Try a different filter.' }}
|
||||
</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 ['Title', 'Type', 'Owner', 'Expiry', 'Ver', '']" :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="d in visible" :key="d.id">
|
||||
<tr class="border-b border-line last:border-0" :class="editingId === d.id ? 'bg-accent-soft' : ''">
|
||||
<td class="px-5 py-3">
|
||||
<div class="font-semibold text-ink">{{ d.title }}</div>
|
||||
<div v-if="d.reference" class="font-mono text-[11px] text-ink-muted">{{ d.reference }}</div>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-ink-secondary">{{ DOC_TYPE_LABEL[d.docType] || d.docType || '—' }}</td>
|
||||
<td class="px-5 py-3 text-ink-secondary">{{ ownerLabel(d) }}</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[expiryBadge(d).tone]"
|
||||
@click="toggleRow(d.id)"
|
||||
>
|
||||
<Icon v-if="expiryBadge(d).icon" :name="expiryBadge(d).icon" :size="12" />
|
||||
{{ expiryBadge(d).label }}
|
||||
</button>
|
||||
<div v-if="d.expiryDate" class="mt-0.5 font-mono text-[10.5px] text-ink-muted">{{ d.expiryDate }}</div>
|
||||
</td>
|
||||
<td class="px-5 py-3 font-mono text-ink-secondary">v{{ d.version || 1 }}</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="removeDoc(d)">Delete</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a v-if="d.hasFile" :href="documentFileUrl(d.id)" class="btn-ghost mr-1 inline-flex items-center gap-1" title="Download file">
|
||||
<Icon name="download" :size="13" />
|
||||
</a>
|
||||
<button class="btn-ghost mr-1 inline-flex items-center gap-1" title="Upload new version" @click="newVersion(d)"><Icon name="upload" :size="13" /></button>
|
||||
<button class="btn-ghost mr-1 inline-flex items-center gap-1" @click="editDoc(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>
|
||||
<tr v-if="openRow === d.id" class="border-b border-line bg-surface-2">
|
||||
<td colspan="6" class="px-5 py-3">
|
||||
<div class="flex flex-wrap gap-x-8 gap-y-1.5 text-xs">
|
||||
<span class="text-ink-secondary">Status: <b class="text-ink">{{ d.status || '—' }}</b></span>
|
||||
<span class="text-ink-secondary">Access: <b class="text-ink">{{ d.accessTier || '—' }}</b></span>
|
||||
<span v-if="d.jurisdiction" class="text-ink-secondary">Jurisdiction: <b class="text-ink">{{ d.jurisdiction }}</b></span>
|
||||
<span v-if="d.issueDate" class="text-ink-secondary">Issued: <b class="font-mono text-ink">{{ d.issueDate }}</b></span>
|
||||
<span v-if="d.expiryDate" class="text-ink-secondary">Expires: <b class="font-mono text-ink">{{ d.expiryDate }}</b></span>
|
||||
<span class="text-ink-secondary">File: <b class="text-ink">{{ d.hasFile ? d.fileName : 'none' }}</b></span>
|
||||
</div>
|
||||
<ul v-if="(d.expiry?.flags || []).length" class="mt-2 space-y-1">
|
||||
<li v-for="(fl, i) in d.expiry.flags" :key="i" class="flex items-start gap-2 text-xs"
|
||||
:class="d.expiry.state === 'expired' ? 'text-danger-fg' : d.expiry.state === 'expiring_soon' ? 'text-amber-fg' : 'text-ink-secondary'">
|
||||
<Icon name="alertTriangle" :size="13" class="mt-px shrink-0" /> {{ fl }}
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else-if="d.expiry?.state === 'valid'" class="mt-2 text-xs text-success-fg">In force — no action needed.</div>
|
||||
<div v-if="d.notes" class="mt-2 text-xs text-ink-secondary"><span class="text-ink-muted">Notes:</span> {{ d.notes }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user