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:
tajniak81
2026-07-17 10:41:01 +02:00
co-authored by Claude Opus 4.8
parent e64c89a564
commit 07192f1238
15 changed files with 449 additions and 202 deletions
@@ -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>