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
@@ -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