Add attachments to service, maintenance, fuel and parts records
Documents already carried a single optional file: upload, download, detach,
access-checked on every request and proxied through this server, so PocketBase's
files are never public URLs. Service records, workshop visits, refills and
catalog parts all want the same thing — a receipt, an invoice, a photo of the
box — so extend it to them.
Rather than copy the document handlers four more times, lift them into one
shared layer. Every attachable collection has a car relation and a file field,
which is what lets a single set of handlers authorize and serve all of them. An
upload finishes by delegating to the collection's own GET handler, so the
response carries the full record — derived fields and all — exactly as a re-read
would. The web side gets the same treatment: one picker component and one
upload-after-save helper behind all five forms. Net effect is five features for
about the cost of the one that was already there.
Alongside:
- parts gain a notes field
- Reminders moves behind Parts catalog in the car detail tabs
- the changed-part labels spell out in full ("Oil & Oil filter" rather than
"Oil & filter"), and the form and table now agree
The PocketBase schema must be migrated before the new attachments work:
scripts/setup-pocketbase.mjs adds the file fields and parts.notes. It is
additive and safe to re-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e64c89a564
commit
07192f1238
+28
-8
@@ -88,6 +88,25 @@ async function requestBlob(path) {
|
||||
return { blob: await res.blob(), filename };
|
||||
}
|
||||
|
||||
// Attachments. Every record that can carry a file — documents, service records,
|
||||
// workshop visits, refills, catalog parts — exposes the same three endpoints
|
||||
// under its own path, so they are built from one place rather than spelled out
|
||||
// five times.
|
||||
//
|
||||
// The file is proxied by the API (PocketBase's files aren't public), so it needs
|
||||
// the auth header — hence a multipart POST and a Blob fetch rather than a plain
|
||||
// <a href>. Uploading addresses a record that must already exist; see
|
||||
// applyAttachment in lib/attachment.js for the order the forms use.
|
||||
const attachment = (path) => ({
|
||||
upload: (id, file) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return requestForm(`${path}/${id}/file`, { method: "POST", body: form });
|
||||
},
|
||||
download: (id) => requestBlob(`${path}/${id}/file`),
|
||||
remove: (id) => request(`${path}/${id}/file`, { method: "DELETE" }),
|
||||
});
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
login: (email, password) =>
|
||||
@@ -145,15 +164,16 @@ export const api = {
|
||||
updateDocument: (id, body) =>
|
||||
request(`/car-documents/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteDocument: (id) => request(`/car-documents/${id}`, { method: "DELETE" }),
|
||||
// The attachment is proxied by the API (PocketBase files aren't public), so it
|
||||
// needs the auth header — hence a Blob fetch rather than a plain link.
|
||||
uploadDocumentFile: (id, file) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return requestForm(`/car-documents/${id}/file`, { method: "POST", body: form });
|
||||
|
||||
// Attachments, one per record. Keyed by the same names CarDetail uses for its
|
||||
// tabs so a table row can reach for the right one generically.
|
||||
files: {
|
||||
documents: attachment("/car-documents"),
|
||||
services: attachment("/service-records"),
|
||||
maintenance: attachment("/maintenance"),
|
||||
fuel: attachment("/fuel-entries"),
|
||||
parts: attachment("/parts"),
|
||||
},
|
||||
getDocumentFileBlob: (id) => requestBlob(`/car-documents/${id}/file`),
|
||||
deleteDocumentFile: (id) => request(`/car-documents/${id}/file`, { method: "DELETE" }),
|
||||
|
||||
// Reminders. The list mixes stored reminders with read-only ones derived from
|
||||
// documents and service records (flagged `auto`; their ids start with "auto:").
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
// The attachment picker shared by every form that can carry a file.
|
||||
//
|
||||
// It only collects intent — a picked file, or a request to detach the existing
|
||||
// one. Actually moving the bytes is the parent's job (applyAttachment), because
|
||||
// the endpoint addresses a record that must already exist.
|
||||
defineProps({
|
||||
// The saved record, when editing; null while creating. Read for the name of
|
||||
// whatever is already attached.
|
||||
record: { type: Object, default: null },
|
||||
file: { type: Object, default: null },
|
||||
remove: { type: Boolean, default: false },
|
||||
legend: { type: String, default: "Attachment" },
|
||||
hint: { type: String, default: "PDF or image, up to 10MB." },
|
||||
});
|
||||
const emit = defineEmits(["update:file", "update:remove"]);
|
||||
|
||||
function onFilePick(e) {
|
||||
const picked = e.target.files?.[0] || null;
|
||||
emit("update:file", picked);
|
||||
// Picking a replacement supersedes a pending detach.
|
||||
if (picked) emit("update:remove", false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset class="rounded-control border border-subtle p-3">
|
||||
<legend class="eyebrow px-1">{{ legend }}</legend>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp,.heic"
|
||||
class="w-full text-sm text-body"
|
||||
@change="onFilePick"
|
||||
/>
|
||||
<p v-if="record?.hasFile && !file && !remove" class="mt-2 flex items-center gap-2 text-xs text-muted">
|
||||
<span>Attached: <span class="data text-strong">{{ record.fileName }}</span></span>
|
||||
<button type="button" class="font-medium text-danger hover:underline" @click="emit('update:remove', true)">
|
||||
Remove
|
||||
</button>
|
||||
</p>
|
||||
<p v-else-if="remove" class="mt-2 flex items-center gap-2 text-xs text-muted">
|
||||
<span>Attachment will be removed on save.</span>
|
||||
<button type="button" class="font-medium text-brandtext hover:underline" @click="emit('update:remove', false)">
|
||||
Undo
|
||||
</button>
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs text-muted">{{ hint }}</p>
|
||||
</fieldset>
|
||||
</template>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { api } from "../api";
|
||||
import { applyAttachment } from "../lib/attachment.js";
|
||||
import AttachmentField from "./AttachmentField.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -34,10 +36,7 @@ const form = ref({
|
||||
notes: props.doc?.notes ?? "",
|
||||
});
|
||||
|
||||
// The picked file is uploaded after the metadata save, since the attachment
|
||||
// endpoint addresses a document that must already exist.
|
||||
const file = ref(null);
|
||||
// Tracks a request to detach the existing attachment without picking a new one.
|
||||
const removeFile = ref(false);
|
||||
|
||||
function toDateInput(value) {
|
||||
@@ -45,11 +44,6 @@ function toDateInput(value) {
|
||||
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function onFilePick(e) {
|
||||
file.value = e.target.files?.[0] || null;
|
||||
if (file.value) removeFile.value = false;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
@@ -57,17 +51,10 @@ async function submit() {
|
||||
const saved = isEdit
|
||||
? await api.updateDocument(props.doc.id, payload())
|
||||
: await api.createDocument(payload());
|
||||
|
||||
// Attachment changes are separate calls; a failure here must not read as a
|
||||
// failed save, because the metadata is already committed.
|
||||
let final = saved;
|
||||
if (file.value) {
|
||||
final = await api.uploadDocumentFile(saved.id, file.value);
|
||||
} else if (removeFile.value && isEdit) {
|
||||
await api.deleteDocumentFile(saved.id);
|
||||
final = { ...saved, fileName: "", hasFile: false };
|
||||
}
|
||||
emit("saved", final);
|
||||
emit("saved", await applyAttachment(api.files.documents, saved, {
|
||||
file: file.value,
|
||||
remove: removeFile.value,
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
@@ -136,19 +123,7 @@ function payload() {
|
||||
<input v-model="form.cost" type="number" step="0.01" min="0" class="dh-input data" />
|
||||
</div>
|
||||
|
||||
<fieldset class="rounded-control border border-subtle p-3">
|
||||
<legend class="eyebrow px-1">Scan or photo</legend>
|
||||
<input type="file" accept=".pdf,.jpg,.jpeg,.png,.webp,.heic" class="w-full text-sm text-body" @change="onFilePick" />
|
||||
<p v-if="isEdit && doc.hasFile && !file && !removeFile" class="mt-2 flex items-center gap-2 text-xs text-muted">
|
||||
<span>Attached: <span class="data text-strong">{{ doc.fileName }}</span></span>
|
||||
<button type="button" class="font-medium text-danger hover:underline" @click="removeFile = true">Remove</button>
|
||||
</p>
|
||||
<p v-else-if="removeFile" class="mt-2 flex items-center gap-2 text-xs text-muted">
|
||||
<span>Attachment will be removed on save.</span>
|
||||
<button type="button" class="font-medium text-brandtext hover:underline" @click="removeFile = false">Undo</button>
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs text-muted">PDF or image, up to 10MB.</p>
|
||||
</fieldset>
|
||||
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="doc" legend="Scan or photo" />
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Notes</label>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { api } from "../api";
|
||||
import { applyAttachment } from "../lib/attachment.js";
|
||||
import AttachmentField from "./AttachmentField.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -26,6 +28,9 @@ const form = ref({
|
||||
notes: props.entry?.notes ?? "",
|
||||
});
|
||||
|
||||
const file = ref(null);
|
||||
const removeFile = ref(false);
|
||||
|
||||
function toDateInput(value) {
|
||||
const d = new Date(value);
|
||||
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
|
||||
@@ -43,7 +48,10 @@ async function submit() {
|
||||
error.value = "";
|
||||
try {
|
||||
const saved = await (isEdit ? api.updateFuel(props.entry.id, payload()) : api.createFuel(payload()));
|
||||
emit("saved", saved);
|
||||
emit("saved", await applyAttachment(api.files.fuel, saved, {
|
||||
file: file.value,
|
||||
remove: removeFile.value,
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
@@ -121,6 +129,8 @@ function payload() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" legend="Receipt" />
|
||||
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
|
||||
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { api } from "../api";
|
||||
import { formatMoney } from "../lib/format.js";
|
||||
import { applyAttachment } from "../lib/attachment.js";
|
||||
import AttachmentField from "./AttachmentField.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -47,6 +49,9 @@ const form = ref({
|
||||
notes: props.entry?.notes ?? "",
|
||||
});
|
||||
|
||||
const file = ref(null);
|
||||
const removeFile = ref(false);
|
||||
|
||||
function toDateInput(value) {
|
||||
const d = new Date(value);
|
||||
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
|
||||
@@ -64,7 +69,10 @@ async function submit() {
|
||||
const saved = await (isEdit
|
||||
? api.updateMaintenance(props.entry.id, payload())
|
||||
: api.createMaintenance(payload()));
|
||||
emit("saved", saved);
|
||||
emit("saved", await applyAttachment(api.files.maintenance, saved, {
|
||||
file: file.value,
|
||||
remove: removeFile.value,
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
@@ -169,6 +177,8 @@ function payload() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" legend="Invoice" />
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Notes</label>
|
||||
<input v-model="form.notes" class="dh-input" />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { api } from "../api";
|
||||
import { applyAttachment } from "../lib/attachment.js";
|
||||
import AttachmentField from "./AttachmentField.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -16,8 +18,12 @@ const form = ref({
|
||||
name: props.part?.name ?? "",
|
||||
partNumber: props.part?.partNumber ?? "",
|
||||
category: props.part?.category ?? "",
|
||||
notes: props.part?.notes ?? "",
|
||||
});
|
||||
|
||||
const file = ref(null);
|
||||
const removeFile = ref(false);
|
||||
|
||||
async function submit() {
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
@@ -27,11 +33,15 @@ async function submit() {
|
||||
name: form.value.name.trim(),
|
||||
partNumber: form.value.partNumber.trim(),
|
||||
category: form.value.category.trim(),
|
||||
notes: form.value.notes.trim(),
|
||||
};
|
||||
const saved = isEdit
|
||||
? await api.updatePart(props.part.id, payload)
|
||||
: await api.createPart(payload);
|
||||
emit("saved", saved);
|
||||
emit("saved", await applyAttachment(api.files.parts, saved, {
|
||||
file: file.value,
|
||||
remove: removeFile.value,
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
@@ -52,6 +62,11 @@ async function submit() {
|
||||
<label class="dh-label">Part number</label>
|
||||
<input v-model="form.partNumber" placeholder="04152-YZZA7" class="dh-input data" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Notes</label>
|
||||
<input v-model="form.notes" placeholder="Fits 2015–2020 · buy in pairs" class="dh-input" />
|
||||
</div>
|
||||
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="part" legend="Photo or spec sheet" />
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
|
||||
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref } from "vue";
|
||||
import { api } from "../api";
|
||||
import { formatKm } from "../lib/format.js";
|
||||
import { applyAttachment } from "../lib/attachment.js";
|
||||
import AttachmentField from "./AttachmentField.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -23,6 +25,9 @@ const form = ref({
|
||||
notes: props.service?.notes ?? "",
|
||||
});
|
||||
|
||||
const file = ref(null);
|
||||
const removeFile = ref(false);
|
||||
|
||||
function toDateInput(value) {
|
||||
const d = new Date(value);
|
||||
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
|
||||
@@ -44,7 +49,10 @@ async function submit() {
|
||||
const saved = isEdit
|
||||
? await api.updateService(props.service.id, payload)
|
||||
: await api.createService(payload);
|
||||
emit("saved", saved);
|
||||
emit("saved", await applyAttachment(api.files.services, saved, {
|
||||
file: file.value,
|
||||
remove: removeFile.value,
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
@@ -69,10 +77,16 @@ async function submit() {
|
||||
</div>
|
||||
<fieldset class="rounded-control border border-subtle p-3">
|
||||
<legend class="eyebrow px-1">Changed parts</legend>
|
||||
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedOil" class="accent-[var(--accent)]" /> Oil & oil filter</label>
|
||||
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedOil" class="accent-[var(--accent)]" /> Oil & Oil filter</label>
|
||||
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedEngineAirFilter" class="accent-[var(--accent)]" /> Engine air filter</label>
|
||||
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedCabinAirFilter" class="accent-[var(--accent)]" /> Cabin air filter</label>
|
||||
</fieldset>
|
||||
<AttachmentField
|
||||
v-model:file="file"
|
||||
v-model:remove="removeFile"
|
||||
:record="service"
|
||||
legend="Receipt or service-book page"
|
||||
/>
|
||||
<div>
|
||||
<label class="dh-label">Notes</label>
|
||||
<input v-model="form.notes" class="dh-input" />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Applies a form's pending attachment change to the record it has just saved,
|
||||
// and returns the record the form should hand back to its parent.
|
||||
//
|
||||
// This necessarily runs after the metadata write: the file endpoints address a
|
||||
// record that must already exist. The order means a create-with-file is two
|
||||
// calls, and the second one failing leaves a saved record with no attachment —
|
||||
// which is why the caller reports it as an attachment error rather than a failed
|
||||
// save, because the metadata is already committed.
|
||||
export async function applyAttachment(files, saved, { file, remove }) {
|
||||
if (file) return files.upload(saved.id, file);
|
||||
if (remove) {
|
||||
await files.remove(saved.id);
|
||||
return { ...saved, fileName: "", hasFile: false };
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
@@ -83,8 +83,8 @@ const TABS = [
|
||||
{ key: "maintenance", label: "Maintenance log" },
|
||||
{ key: "fuel", label: "Fuel" },
|
||||
{ key: "documents", label: "Documents" },
|
||||
{ key: "reminders", label: "Reminders" },
|
||||
{ key: "parts", label: "Parts catalog" },
|
||||
{ key: "reminders", label: "Reminders" },
|
||||
];
|
||||
|
||||
async function load() {
|
||||
@@ -258,15 +258,16 @@ async function deleteDocument(id) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
// The attachment needs the auth header, so it is fetched as a Blob rather than
|
||||
// linked to directly.
|
||||
async function downloadDocument(doc) {
|
||||
// --- attachments ---
|
||||
// The file needs the auth header, so it is fetched as a Blob rather than linked
|
||||
// to directly. `kind` names one of api.files — the same keys as the tabs.
|
||||
async function downloadAttachment(kind, record) {
|
||||
try {
|
||||
const { blob, filename } = await api.getDocumentFileBlob(doc.id);
|
||||
const { blob, filename } = await api.files[kind].download(record.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename || doc.fileName || "document";
|
||||
a.download = filename || record.fileName || "attachment";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
@@ -503,10 +504,11 @@ onMounted(load);
|
||||
<th>Km</th>
|
||||
<th>Next date</th>
|
||||
<th>Next km</th>
|
||||
<th class="!text-center">Oil & filter</th>
|
||||
<th class="!text-center">Engine air</th>
|
||||
<th class="!text-center">Cabin air</th>
|
||||
<th class="!text-center">Oil & Oil filter</th>
|
||||
<th class="!text-center">Engine air filter</th>
|
||||
<th class="!text-center">Cabin air filter</th>
|
||||
<th>Notes</th>
|
||||
<th>File</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -520,6 +522,12 @@ onMounted(load);
|
||||
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedEngineAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedEngineAirFilter) }}</td>
|
||||
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedCabinAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedCabinAirFilter) }}</td>
|
||||
<td class="px-4 py-3 text-body">{{ s.notes || '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<button v-if="s.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('services', s)">
|
||||
Download
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
</td>
|
||||
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditService(s)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteService(s.id)">Delete</button>
|
||||
@@ -558,6 +566,7 @@ onMounted(load);
|
||||
<th>Workshop</th>
|
||||
<th>Status</th>
|
||||
<th class="!text-right">Cost</th>
|
||||
<th>File</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -585,6 +594,12 @@ onMounted(load);
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">
|
||||
{{ m.totalCost ? formatMoney(m.totalCost) : '—' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<button v-if="m.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('maintenance', m)">
|
||||
Download
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
</td>
|
||||
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditMaintenance(m)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteMaintenance(m.id)">Delete</button>
|
||||
@@ -667,6 +682,7 @@ onMounted(load);
|
||||
<th class="!text-right">Distance</th>
|
||||
<th class="!text-right">Consumption</th>
|
||||
<th>Station</th>
|
||||
<th>File</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -689,6 +705,12 @@ onMounted(load);
|
||||
{{ f.station || '—' }}
|
||||
<div v-if="f.notes" class="text-xs text-muted">{{ f.notes }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<button v-if="f.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('fuel', f)">
|
||||
Download
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
</td>
|
||||
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditFuel(f)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteFuel(f.id)">Delete</button>
|
||||
@@ -744,7 +766,7 @@ onMounted(load);
|
||||
<span :class="expiryStatus(d).classes">{{ expiryStatus(d).label }}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<button v-if="d.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadDocument(d)">
|
||||
<button v-if="d.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('documents', d)">
|
||||
Download
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
@@ -827,12 +849,14 @@ onMounted(load);
|
||||
No parts yet.
|
||||
</div>
|
||||
|
||||
<div v-else class="dh-card overflow-hidden p-0">
|
||||
<div v-else class="dh-card overflow-x-auto p-0">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-sunken text-left">
|
||||
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
|
||||
<th>Part</th>
|
||||
<th>Part number</th>
|
||||
<th>Notes</th>
|
||||
<th>File</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -840,6 +864,13 @@ onMounted(load);
|
||||
<tr v-for="p in parts" :key="p.id" class="transition-colors hover:bg-sunken">
|
||||
<td class="px-4 py-3 font-medium text-strong">{{ p.name }}</td>
|
||||
<td class="px-4 py-3 data text-body">{{ p.partNumber || '—' }}</td>
|
||||
<td class="px-4 py-3 text-body">{{ p.notes || '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<button v-if="p.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('parts', p)">
|
||||
Download
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
</td>
|
||||
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditPart(p)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deletePart(p.id)">Delete</button>
|
||||
|
||||
Reference in New Issue
Block a user