Add fuel, maintenance, document and reminder tracking
Four features layered onto cars, each following the existing parts/services pattern: a Go handler gated on requireCarAccess, snake_case PocketBase mappers, a Vue form modal, and a tab on CarDetail (now driven by an array rather than repeated markup). Fuel: refills logged with odometer, litres and cost. Consumption is derived on read from the whole history rather than stored, so correcting an old fill re-derives every window it touches with no rows to migrate. Efficiency uses the full-tank method — two consecutive full tanks are the same known level, so the fuel burned between them is exactly what was poured in. Partial fills roll into the window that closes them; a missed-fill flag leaves that window uncomputed rather than reporting an implausibly good figure. Averages in the stats rollup are distance-weighted, so a long motorway run counts for more than a trip across town — which is what actually happened to the fuel. Maintenance: workshop visits and repairs, deliberately separate from service_records. That collection is the routine interval schedule and drives next-service-due; this one is unplanned garage work with a workshop, an invoice and a labour bill, and no bearing on the interval. Documents: insurance, pollution certificates and registration papers. The renewal date is the point of the record, so expiry is assessed live on every read instead of stored and left to go stale. Scans are proxied through the API — PocketBase's collections have no public read rule, so an attachment is never a public URL and car access is re-checked per fetch. Reminders: fire on a date, an odometer reading, or both (whichever comes first). Stored reminders sit alongside read-only ones derived from document expiry and next-service-due, so a renewal date is never typed twice and can never drift from the document it came from. Derived ids are namespaced "auto:" and every write endpoint rejects them. A refill or a completed visit also writes the car's odometer forward, since it is the freshest reading there is — never backwards, so backfilling old history can't rewind the car. Adds fuel_entries, maintenance_entries, car_documents and reminders to the idempotent schema script, plus a file-field builder for attachments. Verified end-to-end against a live PocketBase with a throwaway account: 39 checks covering the efficiency maths, expiry states, the derived reminders, the upload/download round-trip, and that a stranger can reach none of it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ae6ed4ac1e
commit
e64c89a564
@@ -123,6 +123,47 @@ export const api = {
|
||||
request(`/parts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deletePart: (id) => request(`/parts/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Fuel. Consumption figures on each entry, and the rollup from fuel-stats, are
|
||||
// derived server-side from the full history — nothing here is stored.
|
||||
listCarFuel: (carId) => request(`/cars/${carId}/fuel-entries`),
|
||||
getCarFuelStats: (carId) => request(`/cars/${carId}/fuel-stats`),
|
||||
createFuel: (body) => request("/fuel-entries", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateFuel: (id, body) =>
|
||||
request(`/fuel-entries/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteFuel: (id) => request(`/fuel-entries/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Maintenance log — workshop visits and repairs (not the service schedule).
|
||||
listCarMaintenance: (carId) => request(`/cars/${carId}/maintenance`),
|
||||
createMaintenance: (body) => request("/maintenance", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateMaintenance: (id, body) =>
|
||||
request(`/maintenance/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteMaintenance: (id) => request(`/maintenance/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Documents — insurance, pollution certificates, … with renewal dates.
|
||||
listCarDocuments: (carId) => request(`/cars/${carId}/documents`),
|
||||
createDocument: (body) => request("/car-documents", { method: "POST", body: JSON.stringify(body) }),
|
||||
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 });
|
||||
},
|
||||
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:").
|
||||
listCarReminders: (carId) => request(`/cars/${carId}/reminders`),
|
||||
createReminder: (body) => request("/reminders", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateReminder: (id, body) =>
|
||||
request(`/reminders/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteReminder: (id) => request(`/reminders/${id}`, { method: "DELETE" }),
|
||||
completeReminder: (id) => request(`/reminders/${id}/complete`, { method: "POST" }),
|
||||
|
||||
// Admin — user management (admin or superadmin). Admins are scoped by the
|
||||
// server to their own organization; superadmins see everyone.
|
||||
listUsers: () => request("/users").then((r) => r.users),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { api } from "../api";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
carId: { type: String, required: true },
|
||||
doc: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["saved", "close"]);
|
||||
|
||||
const isEdit = !!props.doc;
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const TYPES = [
|
||||
{ value: "insurance", label: "Insurance" },
|
||||
{ value: "pollution", label: "Pollution certificate" },
|
||||
{ value: "registration", label: "Registration" },
|
||||
{ value: "inspection", label: "Inspection" },
|
||||
{ value: "roadTax", label: "Road tax" },
|
||||
{ value: "warranty", label: "Warranty" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
|
||||
const form = ref({
|
||||
type: props.doc?.type ?? "insurance",
|
||||
title: props.doc?.title ?? "",
|
||||
provider: props.doc?.provider ?? "",
|
||||
reference: props.doc?.reference ?? "",
|
||||
issueDate: props.doc?.issueDate ? toDateInput(props.doc.issueDate) : "",
|
||||
expiryDate: props.doc?.expiryDate ? toDateInput(props.doc.expiryDate) : "",
|
||||
cost: props.doc?.cost ?? "",
|
||||
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) {
|
||||
const d = new Date(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 = "";
|
||||
try {
|
||||
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);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
car: props.carId,
|
||||
type: form.value.type,
|
||||
title: form.value.title.trim(),
|
||||
provider: form.value.provider.trim(),
|
||||
reference: form.value.reference.trim(),
|
||||
issueDate: form.value.issueDate ? new Date(form.value.issueDate).toISOString() : null,
|
||||
expiryDate: form.value.expiryDate ? new Date(form.value.expiryDate).toISOString() : null,
|
||||
cost: form.value.cost ? Number(form.value.cost) : 0,
|
||||
notes: form.value.notes.trim(),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="isEdit ? 'Edit document' : 'Add document'" @close="emit('close')">
|
||||
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
|
||||
<form class="space-y-3" @submit.prevent="submit">
|
||||
<div>
|
||||
<label class="dh-label">Type</label>
|
||||
<select v-model="form.type" class="dh-input">
|
||||
<option v-for="t in TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Title *</label>
|
||||
<input v-model="form.title" required placeholder="Third-party liability 2026" class="dh-input" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="dh-label">Provider</label>
|
||||
<input v-model="form.provider" placeholder="PZU" class="dh-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Policy / certificate no.</label>
|
||||
<input v-model="form.reference" class="dh-input data" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="dh-label">Issued</label>
|
||||
<input v-model="form.issueDate" type="date" class="dh-input data" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Renewal date</label>
|
||||
<input v-model="form.expiryDate" type="date" class="dh-input data" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
Leave the renewal date blank for a document that never expires. Setting it adds a reminder automatically.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Cost</label>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Notes</label>
|
||||
<input v-model="form.notes" class="dh-input" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ saving ? "Saving…" : isEdit ? "Save changes" : "Add document" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { api } from "../api";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
carId: { type: String, required: true },
|
||||
entry: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["saved", "close"]);
|
||||
|
||||
const isEdit = !!props.entry;
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const form = ref({
|
||||
date: props.entry ? toDateInput(props.entry.date) : new Date().toISOString().slice(0, 10),
|
||||
km: props.entry?.km ?? "",
|
||||
liters: props.entry?.liters ?? "",
|
||||
cost: props.entry?.cost ?? "",
|
||||
// A full tank is the common case and the one that makes the entry count
|
||||
// towards efficiency, so it is the default.
|
||||
fullTank: props.entry ? props.entry.fullTank : true,
|
||||
missedFill: props.entry ? props.entry.missedFill : false,
|
||||
station: props.entry?.station ?? "",
|
||||
notes: props.entry?.notes ?? "",
|
||||
});
|
||||
|
||||
function toDateInput(value) {
|
||||
const d = new Date(value);
|
||||
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const pricePerLiter = computed(() => {
|
||||
const l = Number(form.value.liters);
|
||||
const c = Number(form.value.cost);
|
||||
if (!l || !c) return null;
|
||||
return (c / l).toFixed(3);
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const saved = await (isEdit ? api.updateFuel(props.entry.id, payload()) : api.createFuel(payload()));
|
||||
emit("saved", saved);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
car: props.carId,
|
||||
date: new Date(form.value.date).toISOString(),
|
||||
km: form.value.km ? Number(form.value.km) : 0,
|
||||
liters: form.value.liters ? Number(form.value.liters) : 0,
|
||||
cost: form.value.cost ? Number(form.value.cost) : 0,
|
||||
fullTank: form.value.fullTank,
|
||||
missedFill: form.value.missedFill,
|
||||
station: form.value.station.trim(),
|
||||
notes: form.value.notes.trim(),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="isEdit ? 'Edit refill' : 'Log refill'" @close="emit('close')">
|
||||
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
|
||||
<form class="space-y-3" @submit.prevent="submit">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="dh-label">Date *</label>
|
||||
<input v-model="form.date" type="date" required class="dh-input data" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Odometer (km) *</label>
|
||||
<input v-model="form.km" type="number" min="1" required placeholder="16138" class="dh-input data" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="dh-label">Litres *</label>
|
||||
<input v-model="form.liters" type="number" step="0.01" min="0.01" required placeholder="42.5" class="dh-input data" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Total cost</label>
|
||||
<input v-model="form.cost" type="number" step="0.01" min="0" placeholder="285.00" class="dh-input data" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="pricePerLiter" class="text-xs text-muted">
|
||||
Price per litre: <span class="data text-strong">{{ pricePerLiter }}</span>
|
||||
</p>
|
||||
|
||||
<fieldset class="rounded-control border border-subtle p-3">
|
||||
<legend class="eyebrow px-1">Tank</legend>
|
||||
<label class="flex items-center gap-2 py-1 text-sm text-body">
|
||||
<input type="checkbox" v-model="form.fullTank" class="accent-[var(--accent)]" /> Filled to full
|
||||
</label>
|
||||
<label class="flex items-center gap-2 py-1 text-sm text-body">
|
||||
<input type="checkbox" v-model="form.missedFill" class="accent-[var(--accent)]" /> I missed logging a refill before this one
|
||||
</label>
|
||||
<p class="mt-1.5 text-xs text-muted">
|
||||
Consumption is measured between full tanks, so partial fills count towards the next full one.
|
||||
Flagging a missed refill leaves that stretch out of the figures instead of reporting it as unrealistically economical.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="dh-label">Station</label>
|
||||
<input v-model="form.station" placeholder="Orlen" class="dh-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Notes</label>
|
||||
<input v-model="form.notes" class="dh-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ saving ? "Saving…" : isEdit ? "Save changes" : "Log refill" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { api } from "../api";
|
||||
import { formatMoney } from "../lib/format.js";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
carId: { type: String, required: true },
|
||||
entry: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["saved", "close"]);
|
||||
|
||||
const isEdit = !!props.entry;
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const TYPES = [
|
||||
{ value: "repair", label: "Repair" },
|
||||
{ value: "inspection", label: "Inspection" },
|
||||
{ value: "bodywork", label: "Bodywork" },
|
||||
{ value: "tyres", label: "Tyres" },
|
||||
{ value: "diagnostics", label: "Diagnostics" },
|
||||
{ value: "recall", label: "Recall" },
|
||||
{ value: "warranty", label: "Warranty work" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
|
||||
const STATUSES = [
|
||||
{ value: "scheduled", label: "Scheduled" },
|
||||
{ value: "in_progress", label: "In progress" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
];
|
||||
|
||||
const form = ref({
|
||||
date: props.entry ? toDateInput(props.entry.date) : new Date().toISOString().slice(0, 10),
|
||||
km: props.entry?.km ?? "",
|
||||
type: props.entry?.type ?? "repair",
|
||||
status: props.entry?.status ?? "completed",
|
||||
workshop: props.entry?.workshop ?? "",
|
||||
location: props.entry?.location ?? "",
|
||||
description: props.entry?.description ?? "",
|
||||
partsUsed: props.entry?.partsUsed ?? "",
|
||||
laborCost: props.entry?.laborCost ?? "",
|
||||
partsCost: props.entry?.partsCost ?? "",
|
||||
invoiceNumber: props.entry?.invoiceNumber ?? "",
|
||||
warrantyUntil: props.entry?.warrantyUntil ? toDateInput(props.entry.warrantyUntil) : "",
|
||||
notes: props.entry?.notes ?? "",
|
||||
});
|
||||
|
||||
function toDateInput(value) {
|
||||
const d = new Date(value);
|
||||
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const totalCost = computed(() => {
|
||||
const total = Number(form.value.laborCost || 0) + Number(form.value.partsCost || 0);
|
||||
return total > 0 ? formatMoney(total) : null;
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const saved = await (isEdit
|
||||
? api.updateMaintenance(props.entry.id, payload())
|
||||
: api.createMaintenance(payload()));
|
||||
emit("saved", saved);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
car: props.carId,
|
||||
date: new Date(form.value.date).toISOString(),
|
||||
km: form.value.km ? Number(form.value.km) : 0,
|
||||
type: form.value.type,
|
||||
status: form.value.status,
|
||||
workshop: form.value.workshop.trim(),
|
||||
location: form.value.location.trim(),
|
||||
description: form.value.description.trim(),
|
||||
partsUsed: form.value.partsUsed.trim(),
|
||||
laborCost: form.value.laborCost ? Number(form.value.laborCost) : 0,
|
||||
partsCost: form.value.partsCost ? Number(form.value.partsCost) : 0,
|
||||
invoiceNumber: form.value.invoiceNumber.trim(),
|
||||
warrantyUntil: form.value.warrantyUntil ? new Date(form.value.warrantyUntil).toISOString() : null,
|
||||
notes: form.value.notes.trim(),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="isEdit ? 'Edit workshop visit' : 'Log workshop visit'" @close="emit('close')">
|
||||
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
|
||||
<form class="space-y-3" @submit.prevent="submit">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="dh-label">Date *</label>
|
||||
<input v-model="form.date" type="date" required class="dh-input data" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Odometer (km)</label>
|
||||
<input v-model="form.km" type="number" min="0" placeholder="16138" class="dh-input data" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="dh-label">Type</label>
|
||||
<select v-model="form.type" class="dh-input">
|
||||
<option v-for="t in TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Status</label>
|
||||
<select v-model="form.status" class="dh-input">
|
||||
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">What was done *</label>
|
||||
<input v-model="form.description" required placeholder="Replaced alternator and drive belt" class="dh-input" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="dh-label">Workshop</label>
|
||||
<input v-model="form.workshop" placeholder="Auto Serwis Kowalski" class="dh-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Location</label>
|
||||
<input v-model="form.location" placeholder="Kraków" class="dh-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Parts replaced</label>
|
||||
<input v-model="form.partsUsed" placeholder="Alternator 27060-0T010, belt 90916-02660" class="dh-input" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="dh-label">Labour cost</label>
|
||||
<input v-model="form.laborCost" type="number" step="0.01" min="0" class="dh-input data" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Parts cost</label>
|
||||
<input v-model="form.partsCost" type="number" step="0.01" min="0" class="dh-input data" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="totalCost" class="text-xs text-muted">
|
||||
Total: <span class="data text-strong">{{ totalCost }}</span>
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="dh-label">Invoice number</label>
|
||||
<input v-model="form.invoiceNumber" class="dh-input data" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Warranty until</label>
|
||||
<input v-model="form.warrantyUntil" type="date" class="dh-input data" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Notes</label>
|
||||
<input v-model="form.notes" class="dh-input" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ saving ? "Saving…" : isEdit ? "Save changes" : "Log visit" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { api } from "../api";
|
||||
import { formatKm } from "../lib/format.js";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
carId: { type: String, required: true },
|
||||
car: { type: Object, default: null },
|
||||
reminder: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["saved", "close"]);
|
||||
|
||||
const isEdit = !!props.reminder;
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const TYPES = [
|
||||
{ value: "maintenance", label: "Maintenance" },
|
||||
{ value: "document", label: "Document renewal" },
|
||||
{ value: "service", label: "Service" },
|
||||
{ value: "inspection", label: "Inspection" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
|
||||
const form = ref({
|
||||
title: props.reminder?.title ?? "",
|
||||
type: props.reminder?.type ?? "maintenance",
|
||||
dueDate: props.reminder?.dueDate ? toDateInput(props.reminder.dueDate) : "",
|
||||
dueKm: props.reminder?.dueKm || "",
|
||||
repeatDays: props.reminder?.repeatDays || "",
|
||||
repeatKm: props.reminder?.repeatKm || "",
|
||||
notes: props.reminder?.notes ?? "",
|
||||
});
|
||||
|
||||
function toDateInput(value) {
|
||||
const d = new Date(value);
|
||||
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Mirrors the server's rule: a reminder with neither trigger would never fire.
|
||||
const hasTrigger = computed(() => !!form.value.dueDate || Number(form.value.dueKm) > 0);
|
||||
const isRecurring = computed(() => Number(form.value.repeatDays) > 0 || Number(form.value.repeatKm) > 0);
|
||||
|
||||
async function submit() {
|
||||
if (!hasTrigger.value) {
|
||||
error.value = "Set a due date, a due odometer reading, or both.";
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const saved = await (isEdit
|
||||
? api.updateReminder(props.reminder.id, payload())
|
||||
: api.createReminder(payload()));
|
||||
emit("saved", saved);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
car: props.carId,
|
||||
title: form.value.title.trim(),
|
||||
type: form.value.type,
|
||||
dueDate: form.value.dueDate ? new Date(form.value.dueDate).toISOString() : null,
|
||||
dueKm: form.value.dueKm ? Number(form.value.dueKm) : 0,
|
||||
repeatDays: form.value.repeatDays ? Number(form.value.repeatDays) : 0,
|
||||
repeatKm: form.value.repeatKm ? Number(form.value.repeatKm) : 0,
|
||||
// Editing never silently closes a reminder; that is what Done does.
|
||||
done: props.reminder?.done ?? false,
|
||||
notes: form.value.notes.trim(),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="isEdit ? 'Edit reminder' : 'Add reminder'" @close="emit('close')">
|
||||
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
|
||||
<form class="space-y-3" @submit.prevent="submit">
|
||||
<div>
|
||||
<label class="dh-label">Title *</label>
|
||||
<input v-model="form.title" required placeholder="Swap to winter tyres" class="dh-input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Type</label>
|
||||
<select v-model="form.type" class="dh-input">
|
||||
<option v-for="t in TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<fieldset class="rounded-control border border-subtle p-3">
|
||||
<legend class="eyebrow px-1">Remind me</legend>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="dh-label">On date</label>
|
||||
<input v-model="form.dueDate" type="date" class="dh-input data" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">At odometer (km)</label>
|
||||
<input v-model="form.dueKm" type="number" min="0" placeholder="30000" class="dh-input data" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-muted">
|
||||
Set either or both — with both, whichever comes first wins.
|
||||
<span v-if="car?.currentKm"> The car is at {{ formatKm(car.currentKm) }} now.</span>
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="rounded-control border border-subtle p-3">
|
||||
<legend class="eyebrow px-1">Repeat (optional)</legend>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="dh-label">Every … days</label>
|
||||
<input v-model="form.repeatDays" type="number" min="0" placeholder="365" class="dh-input data" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Every … km</label>
|
||||
<input v-model="form.repeatKm" type="number" min="0" placeholder="15000" class="dh-input data" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-muted">
|
||||
<template v-if="isRecurring">
|
||||
Marking this done will roll it forward instead of closing it.
|
||||
</template>
|
||||
<template v-else>
|
||||
Leave blank for a one-off reminder that closes when you mark it done.
|
||||
</template>
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Notes</label>
|
||||
<input v-model="form.notes" class="dh-input" />
|
||||
</div>
|
||||
|
||||
<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 || !hasTrigger" class="dh-btn dh-btn-primary">
|
||||
{{ saving ? "Saving…" : isEdit ? "Save changes" : "Add reminder" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -80,6 +80,100 @@ function kmSignal(currentKm, nextServiceKm) {
|
||||
// serviceStatus combines the date- and km-based signals, returning the worse of
|
||||
// the two for the badge. `latest` is the most recent service record (with
|
||||
// nextServiceDate/nextServiceKm); `car` carries the current odometer.
|
||||
// formatLiters / formatMoney / formatConsumption render the fuel figures. The
|
||||
// server sends null for anything it could not derive (a window with a missed
|
||||
// fill, a first-ever tank), which reads as "—" rather than a misleading zero.
|
||||
export function formatLiters(value) {
|
||||
if (value == null || value === "") return "—";
|
||||
return Number(value).toFixed(2) + " L";
|
||||
}
|
||||
|
||||
// Amounts are unit-less on purpose: the project stores plain numbers and has no
|
||||
// currency setting, so imposing a symbol here would be a guess.
|
||||
export function formatMoney(value) {
|
||||
if (value == null || value === "") return "—";
|
||||
return Number(value).toLocaleString(prefs.locale || undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
// One decimal: the interesting differences between tanks live in tenths, and
|
||||
// rounding to whole litres collapses a best of 6.8 and a worst of 7.0 into the
|
||||
// same number.
|
||||
export function formatConsumption(value) {
|
||||
if (value == null) return "—";
|
||||
return Number(value).toFixed(1) + " L/100km";
|
||||
}
|
||||
|
||||
export function formatKmPerLiter(value) {
|
||||
if (value == null) return "—";
|
||||
return Number(value).toFixed(2) + " km/L";
|
||||
}
|
||||
|
||||
// Document renewal badge, driven by the server's expiry assessment so the client
|
||||
// never re-derives the date maths.
|
||||
const EXPIRY_STYLE = {
|
||||
no_expiry: "dh-badge dh-badge-neutral",
|
||||
valid: "dh-badge dh-badge-success",
|
||||
expiring_soon: "dh-badge dh-badge-warning",
|
||||
expired: "dh-badge dh-badge-danger",
|
||||
};
|
||||
|
||||
export function expiryStatus(doc) {
|
||||
const state = doc?.expiry?.state || "no_expiry";
|
||||
const days = doc?.expiry?.daysUntilExpiry;
|
||||
let label;
|
||||
switch (state) {
|
||||
case "expired":
|
||||
label = `Expired ${Math.abs(days)}d ago`;
|
||||
break;
|
||||
case "expiring_soon":
|
||||
label = days === 0 ? "Expires today" : `Renew in ${days}d`;
|
||||
break;
|
||||
case "valid":
|
||||
label = `Valid · ${days}d`;
|
||||
break;
|
||||
default:
|
||||
label = "No expiry";
|
||||
}
|
||||
return { key: state, label, classes: EXPIRY_STYLE[state] || EXPIRY_STYLE.no_expiry };
|
||||
}
|
||||
|
||||
// Reminder badge. The server has already picked the worse of the date and
|
||||
// odometer signals; this only chooses the wording, preferring whichever trigger
|
||||
// is actually driving the status.
|
||||
const REMINDER_STYLE = {
|
||||
done: "dh-badge dh-badge-neutral",
|
||||
no_trigger: "dh-badge dh-badge-neutral",
|
||||
upcoming: "dh-badge dh-badge-success",
|
||||
due_soon: "dh-badge dh-badge-warning",
|
||||
overdue: "dh-badge dh-badge-danger",
|
||||
};
|
||||
|
||||
export function reminderStatus(rem) {
|
||||
const state = rem?.status || "no_trigger";
|
||||
const days = rem?.daysLeft;
|
||||
const km = rem?.kmLeft;
|
||||
|
||||
let label;
|
||||
if (state === "done") label = "Done";
|
||||
else if (state === "no_trigger") label = "No trigger";
|
||||
else if (state === "overdue") {
|
||||
const parts = [];
|
||||
if (days != null && days < 0) parts.push(`${Math.abs(days)}d`);
|
||||
if (km != null && km < 0) parts.push(`${Math.abs(km).toLocaleString()} km`);
|
||||
label = parts.length ? `Overdue ${parts.join(" · ")}` : "Overdue";
|
||||
} else {
|
||||
// Lead with the trigger that is closest to firing.
|
||||
const parts = [];
|
||||
if (days != null && days >= 0) parts.push(days === 0 ? "today" : `${days}d`);
|
||||
if (km != null && km >= 0) parts.push(`${km.toLocaleString()} km`);
|
||||
label = parts.length ? `Due in ${parts.join(" · ")}` : "Upcoming";
|
||||
}
|
||||
return { key: state, label, classes: REMINDER_STYLE[state] || REMINDER_STYLE.no_trigger };
|
||||
}
|
||||
|
||||
export function serviceStatus(latest, car = null) {
|
||||
const date = dateSignal(latest?.nextServiceDate);
|
||||
const km = kmSignal(car?.currentKm, latest?.nextServiceKm);
|
||||
|
||||
@@ -2,10 +2,24 @@
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { formatDate, formatKm, serviceStatus } from "../lib/format.js";
|
||||
import {
|
||||
formatDate,
|
||||
formatKm,
|
||||
formatLiters,
|
||||
formatMoney,
|
||||
formatConsumption,
|
||||
formatKmPerLiter,
|
||||
serviceStatus,
|
||||
expiryStatus,
|
||||
reminderStatus,
|
||||
} from "../lib/format.js";
|
||||
import CarFormModal from "../components/CarFormModal.vue";
|
||||
import ServiceFormModal from "../components/ServiceFormModal.vue";
|
||||
import PartFormModal from "../components/PartFormModal.vue";
|
||||
import FuelFormModal from "../components/FuelFormModal.vue";
|
||||
import MaintenanceFormModal from "../components/MaintenanceFormModal.vue";
|
||||
import DocumentFormModal from "../components/DocumentFormModal.vue";
|
||||
import ReminderFormModal from "../components/ReminderFormModal.vue";
|
||||
import ShareModal from "../components/ShareModal.vue";
|
||||
|
||||
const props = defineProps({ id: { type: String, required: true } });
|
||||
@@ -14,6 +28,11 @@ const router = useRouter();
|
||||
const car = ref(null);
|
||||
const services = ref([]);
|
||||
const parts = ref([]);
|
||||
const fuel = ref([]);
|
||||
const fuelStats = ref(null);
|
||||
const maintenance = ref([]);
|
||||
const documents = ref([]);
|
||||
const reminders = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
|
||||
@@ -23,6 +42,14 @@ const showService = ref(false);
|
||||
const editingService = ref(null);
|
||||
const showPart = ref(false);
|
||||
const editingPart = ref(null);
|
||||
const showFuel = ref(false);
|
||||
const editingFuel = ref(null);
|
||||
const showMaintenance = ref(false);
|
||||
const editingMaintenance = ref(null);
|
||||
const showDocument = ref(false);
|
||||
const editingDocument = ref(null);
|
||||
const showReminder = ref(false);
|
||||
const editingReminder = ref(null);
|
||||
|
||||
// Delete-car confirmation (guarded: user must type the car name).
|
||||
const showDeleteCar = ref(false);
|
||||
@@ -44,14 +71,44 @@ const isReadOnly = computed(() => car.value?.access === "read");
|
||||
|
||||
const activeTab = ref("info");
|
||||
|
||||
// Count of reminders wanting attention, surfaced on the tab so it is visible
|
||||
// without opening it — the whole point of a reminder.
|
||||
const dueReminders = computed(
|
||||
() => reminders.value.filter((r) => r.status === "overdue" || r.status === "due_soon").length
|
||||
);
|
||||
|
||||
const TABS = [
|
||||
{ key: "info", label: "Information" },
|
||||
{ key: "services", label: "Service history" },
|
||||
{ key: "maintenance", label: "Maintenance log" },
|
||||
{ key: "fuel", label: "Fuel" },
|
||||
{ key: "documents", label: "Documents" },
|
||||
{ key: "reminders", label: "Reminders" },
|
||||
{ key: "parts", label: "Parts catalog" },
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
[car.value, services.value, parts.value] = await Promise.all([
|
||||
[
|
||||
car.value,
|
||||
services.value,
|
||||
parts.value,
|
||||
fuel.value,
|
||||
fuelStats.value,
|
||||
maintenance.value,
|
||||
documents.value,
|
||||
reminders.value,
|
||||
] = await Promise.all([
|
||||
api.getCar(props.id),
|
||||
api.listCarServices(props.id),
|
||||
api.listCarParts(props.id),
|
||||
api.listCarFuel(props.id),
|
||||
api.getCarFuelStats(props.id),
|
||||
api.listCarMaintenance(props.id),
|
||||
api.listCarDocuments(props.id),
|
||||
api.listCarReminders(props.id),
|
||||
]);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
@@ -71,6 +128,7 @@ function openEditService(s) {
|
||||
async function onServiceSaved() {
|
||||
showService.value = false;
|
||||
editingService.value = null;
|
||||
// A service record moves the next-service-due reminder, so reload everything.
|
||||
await load();
|
||||
}
|
||||
async function deleteService(id) {
|
||||
@@ -106,6 +164,166 @@ async function deletePart(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- fuel ---
|
||||
function openAddFuel() {
|
||||
editingFuel.value = null;
|
||||
showFuel.value = true;
|
||||
}
|
||||
function openEditFuel(f) {
|
||||
editingFuel.value = f;
|
||||
showFuel.value = true;
|
||||
}
|
||||
async function reloadFuel() {
|
||||
// Editing one refill re-derives every window it touches, and a refill also
|
||||
// advances the car's odometer — which in turn moves any km-triggered reminder.
|
||||
// So refetch all four, not just the list that was edited.
|
||||
[fuel.value, fuelStats.value, car.value, reminders.value] = await Promise.all([
|
||||
api.listCarFuel(props.id),
|
||||
api.getCarFuelStats(props.id),
|
||||
api.getCar(props.id),
|
||||
api.listCarReminders(props.id),
|
||||
]);
|
||||
}
|
||||
async function onFuelSaved() {
|
||||
showFuel.value = false;
|
||||
editingFuel.value = null;
|
||||
await reloadFuel();
|
||||
}
|
||||
async function deleteFuel(id) {
|
||||
if (!confirm("Delete this refill?")) return;
|
||||
try {
|
||||
await api.deleteFuel(id);
|
||||
await reloadFuel();
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// --- maintenance ---
|
||||
function openAddMaintenance() {
|
||||
editingMaintenance.value = null;
|
||||
showMaintenance.value = true;
|
||||
}
|
||||
function openEditMaintenance(m) {
|
||||
editingMaintenance.value = m;
|
||||
showMaintenance.value = true;
|
||||
}
|
||||
async function onMaintenanceSaved() {
|
||||
showMaintenance.value = false;
|
||||
editingMaintenance.value = null;
|
||||
// A completed visit advances the odometer, which moves km-triggered reminders.
|
||||
[maintenance.value, car.value, reminders.value] = await Promise.all([
|
||||
api.listCarMaintenance(props.id),
|
||||
api.getCar(props.id),
|
||||
api.listCarReminders(props.id),
|
||||
]);
|
||||
}
|
||||
async function deleteMaintenance(id) {
|
||||
if (!confirm("Delete this workshop visit?")) return;
|
||||
try {
|
||||
await api.deleteMaintenance(id);
|
||||
maintenance.value = await api.listCarMaintenance(props.id);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// --- documents ---
|
||||
function openAddDocument() {
|
||||
editingDocument.value = null;
|
||||
showDocument.value = true;
|
||||
}
|
||||
function openEditDocument(d) {
|
||||
editingDocument.value = d;
|
||||
showDocument.value = true;
|
||||
}
|
||||
async function onDocumentSaved() {
|
||||
showDocument.value = false;
|
||||
editingDocument.value = null;
|
||||
// A renewal date change adds/moves an auto-derived reminder.
|
||||
[documents.value, reminders.value] = await Promise.all([
|
||||
api.listCarDocuments(props.id),
|
||||
api.listCarReminders(props.id),
|
||||
]);
|
||||
}
|
||||
async function deleteDocument(id) {
|
||||
if (!confirm("Delete this document?")) return;
|
||||
try {
|
||||
await api.deleteDocument(id);
|
||||
[documents.value, reminders.value] = await Promise.all([
|
||||
api.listCarDocuments(props.id),
|
||||
api.listCarReminders(props.id),
|
||||
]);
|
||||
} catch (e) {
|
||||
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) {
|
||||
try {
|
||||
const { blob, filename } = await api.getDocumentFileBlob(doc.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename || doc.fileName || "document";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// --- reminders ---
|
||||
function openAddReminder() {
|
||||
editingReminder.value = null;
|
||||
showReminder.value = true;
|
||||
}
|
||||
function openEditReminder(r) {
|
||||
editingReminder.value = r;
|
||||
showReminder.value = true;
|
||||
}
|
||||
async function onReminderSaved() {
|
||||
showReminder.value = false;
|
||||
editingReminder.value = null;
|
||||
reminders.value = await api.listCarReminders(props.id);
|
||||
}
|
||||
async function completeReminder(r) {
|
||||
try {
|
||||
await api.completeReminder(r.id);
|
||||
reminders.value = await api.listCarReminders(props.id);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
async function reopenReminder(r) {
|
||||
try {
|
||||
await api.updateReminder(r.id, {
|
||||
car: r.car,
|
||||
title: r.title,
|
||||
type: r.type,
|
||||
dueDate: r.dueDate ?? null,
|
||||
dueKm: r.dueKm || 0,
|
||||
repeatDays: r.repeatDays || 0,
|
||||
repeatKm: r.repeatKm || 0,
|
||||
done: false,
|
||||
notes: r.notes || "",
|
||||
});
|
||||
reminders.value = await api.listCarReminders(props.id);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
async function deleteReminder(id) {
|
||||
if (!confirm("Delete this reminder?")) return;
|
||||
try {
|
||||
await api.deleteReminder(id);
|
||||
reminders.value = await api.listCarReminders(props.id);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function onCarSaved(updated) {
|
||||
showCarEdit.value = false;
|
||||
car.value = updated;
|
||||
@@ -148,6 +366,45 @@ function fuelLabel(v) {
|
||||
return FUEL_LABELS[v] || "—";
|
||||
}
|
||||
|
||||
const MAINTENANCE_LABELS = {
|
||||
repair: "Repair",
|
||||
inspection: "Inspection",
|
||||
bodywork: "Bodywork",
|
||||
tyres: "Tyres",
|
||||
diagnostics: "Diagnostics",
|
||||
recall: "Recall",
|
||||
warranty: "Warranty work",
|
||||
other: "Other",
|
||||
};
|
||||
const MAINTENANCE_STATUS = {
|
||||
scheduled: "dh-badge dh-badge-warning",
|
||||
in_progress: "dh-badge dh-badge-warning",
|
||||
completed: "dh-badge dh-badge-success",
|
||||
};
|
||||
const MAINTENANCE_STATUS_LABELS = {
|
||||
scheduled: "Scheduled",
|
||||
in_progress: "In progress",
|
||||
completed: "Completed",
|
||||
};
|
||||
|
||||
const DOCUMENT_LABELS = {
|
||||
insurance: "Insurance",
|
||||
pollution: "Pollution certificate",
|
||||
registration: "Registration",
|
||||
inspection: "Inspection",
|
||||
roadTax: "Road tax",
|
||||
warranty: "Warranty",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
const REMINDER_LABELS = {
|
||||
maintenance: "Maintenance",
|
||||
document: "Document",
|
||||
service: "Service",
|
||||
inspection: "Inspection",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -184,30 +441,21 @@ onMounted(load);
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mb-6 flex gap-1 border-b border-subtle">
|
||||
<div class="mb-6 flex flex-wrap gap-1 border-b border-subtle">
|
||||
<button
|
||||
class="-mb-px border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
|
||||
:class="activeTab === 'info'
|
||||
v-for="t in TABS"
|
||||
:key="t.key"
|
||||
class="-mb-px flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
|
||||
:class="activeTab === t.key
|
||||
? 'border-accent text-brandtext'
|
||||
: 'border-transparent text-muted hover:text-strong'"
|
||||
@click="activeTab = 'info'">
|
||||
Information
|
||||
</button>
|
||||
<button
|
||||
class="-mb-px border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
|
||||
:class="activeTab === 'services'
|
||||
? 'border-accent text-brandtext'
|
||||
: 'border-transparent text-muted hover:text-strong'"
|
||||
@click="activeTab = 'services'">
|
||||
Service history
|
||||
</button>
|
||||
<button
|
||||
class="-mb-px border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
|
||||
:class="activeTab === 'parts'
|
||||
? 'border-accent text-brandtext'
|
||||
: 'border-transparent text-muted hover:text-strong'"
|
||||
@click="activeTab = 'parts'">
|
||||
Parts catalog
|
||||
@click="activeTab = t.key">
|
||||
{{ t.label }}
|
||||
<span
|
||||
v-if="t.key === 'reminders' && dueReminders"
|
||||
class="rounded-full bg-danger px-1.5 py-0.5 text-[10px] font-bold leading-none text-white">
|
||||
{{ dueReminders }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -282,8 +530,291 @@ onMounted(load);
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Maintenance log -->
|
||||
<section v-else-if="activeTab === 'maintenance'">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Maintenance log</h2>
|
||||
<p class="text-sm text-muted">Workshop visits and repairs. Routine servicing lives under Service history.</p>
|
||||
</div>
|
||||
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddMaintenance">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
|
||||
Log visit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="maintenance.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
|
||||
No workshop visits logged yet.
|
||||
</div>
|
||||
|
||||
<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>Date</th>
|
||||
<th>Km</th>
|
||||
<th>Type</th>
|
||||
<th>Work done</th>
|
||||
<th>Workshop</th>
|
||||
<th>Status</th>
|
||||
<th class="!text-right">Cost</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-subtle">
|
||||
<tr v-for="m in maintenance" :key="m.id" class="transition-colors hover:bg-sunken">
|
||||
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">{{ formatDate(m.date) }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ formatKm(m.km) }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-body">{{ MAINTENANCE_LABELS[m.type] || m.type }}</td>
|
||||
<td class="px-4 py-3 text-body">
|
||||
<div class="font-medium text-strong">{{ m.description }}</div>
|
||||
<div v-if="m.partsUsed" class="text-xs text-muted">{{ m.partsUsed }}</div>
|
||||
<div v-if="m.warrantyActive" class="mt-0.5 text-xs text-success">
|
||||
Under warranty · {{ m.warrantyDaysLeft }}d left
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-body">
|
||||
{{ m.workshop || '—' }}
|
||||
<div v-if="m.location" class="text-xs text-muted">{{ m.location }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span :class="MAINTENANCE_STATUS[m.status] || 'dh-badge dh-badge-neutral'">
|
||||
{{ MAINTENANCE_STATUS_LABELS[m.status] || m.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">
|
||||
{{ m.totalCost ? formatMoney(m.totalCost) : '—' }}
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Fuel -->
|
||||
<section v-else-if="activeTab === 'fuel'">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Fuel</h2>
|
||||
<p class="text-sm text-muted">Consumption is measured between full tanks.</p>
|
||||
</div>
|
||||
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddFuel">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
|
||||
Log refill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Rollup -->
|
||||
<div v-if="fuelStats && fuelStats.entries > 0" class="dh-card mb-4 p-6">
|
||||
<dl class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<dt class="eyebrow">Average</dt>
|
||||
<dd class="mt-0.5 data text-lg font-bold text-strong">{{ formatConsumption(fuelStats.avgConsumptionL100) }}</dd>
|
||||
<dd class="text-xs text-muted">{{ formatKmPerLiter(fuelStats.avgKmPerLiter) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="eyebrow">Best</dt>
|
||||
<dd class="mt-0.5 data font-medium text-success">{{ formatConsumption(fuelStats.bestConsumptionL100) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="eyebrow">Worst</dt>
|
||||
<dd class="mt-0.5 data font-medium text-danger">{{ formatConsumption(fuelStats.worstConsumptionL100) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="eyebrow">Cost per km</dt>
|
||||
<dd class="mt-0.5 data font-medium text-strong">{{ formatMoney(fuelStats.costPerKm) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="eyebrow">Refills</dt>
|
||||
<dd class="mt-0.5 data font-medium text-strong">{{ fuelStats.entries }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="eyebrow">Total litres</dt>
|
||||
<dd class="mt-0.5 data font-medium text-strong">{{ formatLiters(fuelStats.totalLiters) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="eyebrow">Total spent</dt>
|
||||
<dd class="mt-0.5 data font-medium text-strong">{{ formatMoney(fuelStats.totalCost) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="eyebrow">Tracked distance</dt>
|
||||
<dd class="mt-0.5 data font-medium text-strong">{{ formatKm(fuelStats.trackedDistanceKm) }}</dd>
|
||||
<dd class="text-xs text-muted">Avg. price {{ formatMoney(fuelStats.avgPricePerLiter) }}/L</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-if="!fuelStats.avgConsumptionL100" class="mt-4 text-xs text-muted">
|
||||
Log at least two full tanks to see consumption figures.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="fuel.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
|
||||
No refills logged yet.
|
||||
</div>
|
||||
|
||||
<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>Date</th>
|
||||
<th>Km</th>
|
||||
<th class="!text-right">Litres</th>
|
||||
<th class="!text-right">Cost</th>
|
||||
<th class="!text-right">Per litre</th>
|
||||
<th class="!text-right">Distance</th>
|
||||
<th class="!text-right">Consumption</th>
|
||||
<th>Station</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-subtle">
|
||||
<tr v-for="f in fuel" :key="f.id" class="transition-colors hover:bg-sunken">
|
||||
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">
|
||||
{{ formatDate(f.date) }}
|
||||
<span v-if="!f.fullTank" class="ml-1 text-xs font-normal text-muted">partial</span>
|
||||
<span v-if="f.missedFill" class="ml-1 text-xs font-normal text-warning">gap</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ formatKm(f.km) }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">{{ formatLiters(f.liters) }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">{{ f.cost ? formatMoney(f.cost) : '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right data text-muted">{{ formatMoney(f.pricePerLiter) }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right data text-muted">{{ f.distanceKm ? formatKm(f.distanceKm) : '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right data font-medium" :class="f.consumptionL100 ? 'text-strong' : 'text-muted'">
|
||||
{{ formatConsumption(f.consumptionL100) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-body">
|
||||
{{ f.station || '—' }}
|
||||
<div v-if="f.notes" class="text-xs text-muted">{{ f.notes }}</div>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Documents -->
|
||||
<section v-else-if="activeTab === 'documents'">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Documents</h2>
|
||||
<p class="text-sm text-muted">Insurance, pollution certificates and other paperwork with renewal dates.</p>
|
||||
</div>
|
||||
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddDocument">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
|
||||
Add document
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="documents.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
|
||||
No documents yet.
|
||||
</div>
|
||||
|
||||
<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>Type</th>
|
||||
<th>Title</th>
|
||||
<th>Provider</th>
|
||||
<th>Issued</th>
|
||||
<th>Renewal</th>
|
||||
<th>Status</th>
|
||||
<th>File</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-subtle">
|
||||
<tr v-for="d in documents" :key="d.id" class="transition-colors hover:bg-sunken">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-body">{{ DOCUMENT_LABELS[d.type] || d.type }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-strong">{{ d.title }}</div>
|
||||
<div v-if="d.reference" class="data text-xs text-muted">{{ d.reference }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-body">{{ d.provider || '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ d.issueDate ? formatDate(d.issueDate) : '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ d.expiryDate ? formatDate(d.expiryDate) : '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<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)">
|
||||
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="openEditDocument(d)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteDocument(d.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Reminders -->
|
||||
<section v-else-if="activeTab === 'reminders'">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Reminders</h2>
|
||||
<p class="text-sm text-muted">Renewal and service reminders are added automatically from your documents and service history.</p>
|
||||
</div>
|
||||
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddReminder">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
|
||||
Add reminder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="reminders.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
|
||||
Nothing to be reminded about yet.
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="r in reminders"
|
||||
:key="r.id"
|
||||
class="dh-card flex flex-wrap items-center justify-between gap-3 p-4"
|
||||
:class="r.done ? 'opacity-60' : ''">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-strong" :class="r.done ? 'line-through' : ''">{{ r.title }}</span>
|
||||
<span class="dh-badge dh-badge-neutral">{{ REMINDER_LABELS[r.type] || r.type }}</span>
|
||||
<span v-if="r.auto" class="dh-badge dh-badge-neutral">Automatic</span>
|
||||
<span v-if="r.repeatDays || r.repeatKm" class="dh-badge dh-badge-neutral">Repeats</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
<span v-if="r.dueDate" class="data">{{ formatDate(r.dueDate) }}</span>
|
||||
<span v-if="r.dueDate && r.dueKm"> · </span>
|
||||
<span v-if="r.dueKm" class="data">at {{ formatKm(r.dueKm) }}</span>
|
||||
<span v-if="r.notes"> · {{ r.notes }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span :class="reminderStatus(r).classes">{{ reminderStatus(r).label }}</span>
|
||||
<!-- Auto reminders have no row behind them: they clear by renewing
|
||||
the document or logging the service they came from. -->
|
||||
<template v-if="canWrite && !r.auto">
|
||||
<button v-if="!r.done" class="text-xs font-medium text-success hover:underline" @click="completeReminder(r)">
|
||||
{{ r.repeatDays || r.repeatKm ? 'Done · roll forward' : 'Mark done' }}
|
||||
</button>
|
||||
<button v-else class="text-xs font-medium text-brandtext hover:underline" @click="reopenReminder(r)">Reopen</button>
|
||||
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditReminder(r)">Edit</button>
|
||||
<button class="text-xs font-medium text-danger hover:underline" @click="deleteReminder(r.id)">Delete</button>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Parts catalog -->
|
||||
<section v-else>
|
||||
<section v-else-if="activeTab === 'parts'">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Parts catalog</h2>
|
||||
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddPart">
|
||||
@@ -337,6 +868,35 @@ onMounted(load);
|
||||
@saved="onPartSaved"
|
||||
@close="showPart = false"
|
||||
/>
|
||||
<FuelFormModal
|
||||
v-if="showFuel"
|
||||
:car-id="id"
|
||||
:entry="editingFuel"
|
||||
@saved="onFuelSaved"
|
||||
@close="showFuel = false"
|
||||
/>
|
||||
<MaintenanceFormModal
|
||||
v-if="showMaintenance"
|
||||
:car-id="id"
|
||||
:entry="editingMaintenance"
|
||||
@saved="onMaintenanceSaved"
|
||||
@close="showMaintenance = false"
|
||||
/>
|
||||
<DocumentFormModal
|
||||
v-if="showDocument"
|
||||
:car-id="id"
|
||||
:doc="editingDocument"
|
||||
@saved="onDocumentSaved"
|
||||
@close="showDocument = false"
|
||||
/>
|
||||
<ReminderFormModal
|
||||
v-if="showReminder"
|
||||
:car-id="id"
|
||||
:car="car"
|
||||
:reminder="editingReminder"
|
||||
@saved="onReminderSaved"
|
||||
@close="showReminder = false"
|
||||
/>
|
||||
<ShareModal v-if="showShare && car" :car="car" @close="showShare = false" />
|
||||
|
||||
<!-- Delete-car confirmation (type-to-confirm; cascade removes all data) -->
|
||||
@@ -344,9 +904,12 @@ onMounted(load);
|
||||
<div class="dh-card w-full max-w-md p-6 shadow-pop">
|
||||
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">Delete this car?</h2>
|
||||
<p class="mb-4 text-sm text-body">
|
||||
This permanently deletes <strong class="text-strong">{{ car.name }}</strong> and all of its
|
||||
<strong class="text-strong">{{ services.length }}</strong> service record{{ services.length === 1 ? '' : 's' }}
|
||||
and <strong class="text-strong">{{ parts.length }}</strong> part{{ parts.length === 1 ? '' : 's' }}. This cannot be undone.
|
||||
This permanently deletes <strong class="text-strong">{{ car.name }}</strong> and everything logged against it —
|
||||
<strong class="text-strong">{{ services.length }}</strong> service record{{ services.length === 1 ? '' : 's' }},
|
||||
<strong class="text-strong">{{ maintenance.length }}</strong> workshop visit{{ maintenance.length === 1 ? '' : 's' }},
|
||||
<strong class="text-strong">{{ fuel.length }}</strong> refill{{ fuel.length === 1 ? '' : 's' }},
|
||||
<strong class="text-strong">{{ documents.length }}</strong> document{{ documents.length === 1 ? '' : 's' }} and
|
||||
<strong class="text-strong">{{ parts.length }}</strong> part{{ parts.length === 1 ? '' : 's' }}. This cannot be undone.
|
||||
</p>
|
||||
<label class="dh-label">
|
||||
Type <span class="data text-strong">{{ car.name }}</span> to confirm
|
||||
|
||||
Reference in New Issue
Block a user