Files
DriverVault/Web App/web/src/views/CarDetail.vue
T
tajniak81andClaude Opus 5 bc798dae49 Cars: drag the Information rows into the order you want
The rows on a car's Information tab now take a drag: they reorder as the
pointer crosses them and the arrangement saves on drop — or on dragend,
since a row released in the gap between rows never produces a drop and
would otherwise revert on the next load. Same native drag events as the
garage, so also pointer-only, and it needs write access.

The order belongs to the car, like the choice of which rows show at all,
so everyone it is shared with sees the same page. It is stored as the
full list of the 14 keys, hidden rows included: a row switched off and
back on returns to where it was rather than to the end. A key the stored
arrangement doesn't mention — a row added in a later release — follows
the arranged ones, the same rule the garage uses for a car added since
the last drag.

fieldOrder rides on the existing PUT /api/cars/{id}/view, which writes
only the lists it is given, so a drag never has to resend what is hidden.
A partial arrangement is accepted; an invented key is still a 400, which
is why normalizeHidden is now normalizeKeys — it validates an order as
well as a switched-off set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:52:21 +02:00

1356 lines
60 KiB
Vue

<script setup>
import { ref, onMounted, computed, watch } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api";
import {
formatDate,
formatKm,
formatLiters,
formatMoney,
formatConsumption,
formatKmPerLiter,
serviceStatus,
expiryStatus,
reminderStatus,
} from "../lib/format.js";
import { t, tSplit } from "../i18n";
import CarFormModal from "../components/CarFormModal.vue";
import ServiceFormModal from "../components/ServiceFormModal.vue";
import TechnicalCheckFormModal from "../components/TechnicalCheckFormModal.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";
import ProviderPanel from "../components/ProviderPanel.vue";
import Modal from "../components/Modal.vue";
const props = defineProps({ id: { type: String, required: true } });
const router = useRouter();
const car = ref(null);
const services = ref([]);
const technicalChecks = 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("");
// Modal state. `editing*` null => create mode; an object => edit that record.
const showCarEdit = ref(false);
const showService = ref(false);
const editingService = ref(null);
const showTechnicalCheck = ref(false);
const editingTechnicalCheck = 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);
const deleteConfirmText = ref("");
const deletingCar = ref(false);
const showShare = ref(false);
const latest = computed(() => services.value[0] || null);
const status = computed(() => serviceStatus(latest.value, car.value));
// Access gating. Owner can do everything (incl. delete + sharing); a "write"
// sharee can edit the car and its records/parts but not delete/share; "read"
// is view-only. Default to owner while loading so nothing flickers as editable
// for a read-only user (car is null until loaded, so buttons are hidden anyway).
const isOwner = computed(() => car.value?.access === "owner");
const canWrite = computed(() => car.value?.access === "owner" || car.value?.access === "write");
const isReadOnly = computed(() => car.value?.access === "read");
const activeTab = ref("info");
// Connected-service tab. It leads the bar — ahead of Information — because for a
// car imported from a manufacturer account that is the live view of the car,
// while everything to its right is the record the user keeps by hand.
//
// It shows for a linked car (labelled with the service, "MyToyota") and also for
// an unlinked one as long as the user has some account connected, where it offers
// to link this car to a vehicle on it. A user with nothing connected never sees
// the tab at all.
const providers = ref([]);
const providerLabel = computed(() => {
const linked = providers.value.find((p) => p.id === car.value?.provider);
return linked?.label || car.value?.provider || t("car.tabs.connected");
});
const showProviderTab = computed(
() => !!car.value?.provider || providers.value.some((p) => p.connected)
);
// 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
);
// Computed, not a plain array: t() reads the reactive locale, so the tab labels
// have to re-evaluate when the language changes.
//
// Tabs switched off for this car are dropped here. It is a property of the car,
// so everyone it is shared with sees the same page — an EV with Fuel off has no
// Fuel tab for anybody. Information always stays: it is the car itself, and a
// page with no tabs left would be a dead end. The panels below are keyed off
// activeTab, so a hidden tab's content is unreachable rather than unlabelled.
const hiddenTabs = computed(() => car.value?.hiddenTabs || []);
const hiddenFields = computed(() => car.value?.hiddenFields || []);
const TABS = computed(() =>
[
...(showProviderTab.value ? [{ key: "provider", label: providerLabel.value }] : []),
{ key: "info", label: t("car.tabs.info") },
{ key: "services", label: t("car.tabs.services") },
{ key: "technical", label: t("car.tabs.technical") },
{ key: "maintenance", label: t("car.tabs.maintenance") },
{ key: "fuel", label: t("car.tabs.fuel") },
{ key: "documents", label: t("car.tabs.documents") },
{ key: "parts", label: t("car.tabs.parts") },
{ key: "reminders", label: t("car.tabs.reminders") },
].filter((tab) => !hiddenTabs.value.includes(tab.key))
);
// Switching a tab off while standing on it (or landing on a car whose provider
// tab doesn't apply) would otherwise leave the page on a tab that no longer has
// a button.
watch(TABS, (tabs) => {
if (tabs.length && !tabs.some((tab) => tab.key === activeTab.value)) {
activeTab.value = tabs[0].key;
}
});
// --- What this car's page shows (write access; owner or write-shared) ---
//
// Two hidden sets, both properties of the car: the tabs, and the rows of the
// Information tab. Edited as a draft in one modal and saved together, rather
// than saving on every checkbox: switching several off one at a time would make
// the page rearrange under the pointer between clicks.
const showViewPicker = ref(false);
const HIDEABLE_TABS = [
"provider", "services", "technical", "maintenance", "fuel", "documents", "parts", "reminders",
];
// The Information rows, in their default order. Keys mirror hideableCarFields
// in the API's cars.go — the server rejects anything else.
const INFO_FIELD_KEYS = [
"oilSpec", "transmissionOil", "differentialOil", "brakeFluid", "coolant",
"odometer", "serviceInterval", "nextDue", "registrationPlate",
"registrationCountry", "vin", "fuelType", "buildDate", "firstRegistration",
];
const tabDraft = ref([]); // tab keys that stay visible
const fieldDraft = ref([]); // Information keys that stay visible
const viewSaving = ref(false);
const viewError = ref("");
function openViewPicker() {
tabDraft.value = HIDEABLE_TABS.filter((key) => !hiddenTabs.value.includes(key));
fieldDraft.value = fieldKeys.value.filter((key) => !hiddenFields.value.includes(key));
viewError.value = "";
showViewPicker.value = true;
}
// One handler per draft rather than passing the ref in from the template: Vue
// unwraps refs in the render context, so a shared handler would be handed the
// plain array and its writes would go nowhere.
function toggleTabDraft(key, on) {
tabDraft.value = on ? [...tabDraft.value, key] : tabDraft.value.filter((k) => k !== key);
}
function toggleFieldDraft(key, on) {
fieldDraft.value = on ? [...fieldDraft.value, key] : fieldDraft.value.filter((k) => k !== key);
}
async function saveView() {
viewSaving.value = true;
viewError.value = "";
try {
const updated = await api.updateCarView(props.id, {
hiddenTabs: HIDEABLE_TABS.filter((key) => !tabDraft.value.includes(key)),
hiddenFields: INFO_FIELD_KEYS.filter((key) => !fieldDraft.value.includes(key)),
});
car.value = { ...updated, access: car.value.access };
showViewPicker.value = false;
} catch (e) {
viewError.value = e.message;
} finally {
viewSaving.value = false;
}
}
// The connected-service tab is only offered when this car could show one at all
// — hiding a tab nobody can see would just be confusing.
const tabPickerKeys = computed(() =>
HIDEABLE_TABS.filter((key) => key !== "provider" || showProviderTab.value)
);
function tabPickerLabel(key) {
return key === "provider" ? providerLabel.value : t(`car.tabs.${key}`);
}
function infoFieldLabel(key) {
return t(`car.info.${key}`);
}
// --- The arrangement of the Information rows ---
//
// The full order of all 14 keys, hidden ones included, so a row switched back on
// returns to where it was rather than to the end. Kept as its own ref rather
// than read off the car, because a drag rearranges it live and only saves on
// drop. Rebuilt whenever the car is (re)loaded.
const fieldKeys = ref([...INFO_FIELD_KEYS]);
watch(
() => car.value?.fieldOrder,
(order) => {
const arranged = [];
for (const key of order || []) {
if (INFO_FIELD_KEYS.includes(key) && !arranged.includes(key)) arranged.push(key);
}
// Anything the stored arrangement doesn't mention — a row added in a later
// release — follows the arranged ones, so it shows up at the end rather than
// in the middle of somebody's layout. Matches what the garage does.
fieldKeys.value = [...arranged, ...INFO_FIELD_KEYS.filter((k) => !arranged.includes(k))];
},
{ immediate: true }
);
// Dragging a row, on the same native drag events as the garage — hand-rolled
// rather than a drag library, which does mean it is pointer-only, as touch
// browsers don't fire these. Needs write access, and there is nothing to
// rearrange with a single row showing.
const canArrangeFields = computed(() => canWrite.value && infoFields.value.length > 1);
const dragField = ref(""); // row being dragged
const dropField = ref(""); // row it is currently hovering over
const fieldOrderError = ref("");
let fieldsMoved = false; // the grid changed during this drag and isn't saved yet
function onFieldDragStart(key, e) {
dragField.value = key;
fieldsMoved = false;
e.dataTransfer.effectAllowed = "move";
// Firefox only starts a drag once something is on the transfer.
e.dataTransfer.setData("text/plain", key);
}
// Reorder live as the pointer crosses rows, so the grid shows the arrangement
// you are about to get. dragenter fires again for every child element inside the
// same row, so the row being hovered is remembered and only a genuinely new one
// moves anything. The splice works on the full list, hidden rows included, which
// keeps a hidden row anchored between the same two visible neighbours.
function onFieldDragEnter(key) {
if (!dragField.value || key === dragField.value || dropField.value === key) return;
dropField.value = key;
const list = fieldKeys.value;
const from = list.indexOf(dragField.value);
const to = list.indexOf(key);
if (from < 0 || to < 0) return;
list.splice(to, 0, ...list.splice(from, 1));
fieldsMoved = true;
}
// Save whatever the grid now shows. Called from both drop and dragend: a row
// released over the gap between rows never produces a drop, and leaving that
// arrangement unsaved would quietly undo itself on the next load.
async function commitFieldOrder() {
dragField.value = "";
dropField.value = "";
if (!fieldsMoved) return;
fieldsMoved = false;
fieldOrderError.value = "";
try {
const updated = await api.updateCarView(props.id, { fieldOrder: fieldKeys.value });
car.value = { ...updated, access: car.value.access };
} catch (e) {
// The arrangement didn't stick; say so and put the stored one back rather
// than leaving the page showing an order the server doesn't have.
fieldOrderError.value = e.message;
await load();
}
}
// The Information rows as data, so the same list drives both the grid and the
// picker and the two can't drift apart. `mono` marks the values that read as
// figures rather than prose.
const infoFields = computed(() => {
const c = car.value;
if (!c) return [];
const values = {
oilSpec: { text: c.oilSpec || t("common.empty") },
transmissionOil: { text: c.transmissionOilSpec || t("common.empty") },
differentialOil: { text: c.differentialOilSpec || t("common.empty") },
brakeFluid: { text: c.brakeFluidSpec || t("common.empty") },
coolant: { text: c.coolantSpec || t("common.empty") },
odometer: { text: formatKm(c.currentKm), mono: true },
serviceInterval: { text: `${c.serviceIntervalDays}d · ${formatKm(c.serviceIntervalKm)}`, mono: true },
nextDue: {
text: `${formatDate(latest.value?.nextServiceDate)} · ${formatKm(latest.value?.nextServiceKm)}`,
mono: true,
},
registrationPlate: { text: c.registration || t("common.empty"), mono: true },
registrationCountry: { text: c.registrationCountry || t("common.empty") },
vin: { text: c.vin || t("common.empty"), mono: true },
fuelType: { text: fuelLabel(c.fuelType) },
buildDate: { text: c.buildDate ? formatDate(c.buildDate) : t("common.empty"), mono: true },
firstRegistration: {
text: c.firstRegistrationDate ? formatDate(c.firstRegistrationDate) : t("common.empty"),
mono: true,
},
};
return fieldKeys.value.filter((key) => !hiddenFields.value.includes(key)).map((key) => ({
key,
label: infoFieldLabel(key),
...values[key],
}));
});
async function load() {
loading.value = true;
error.value = "";
try {
[
car.value,
services.value,
technicalChecks.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.listCarTechnicalChecks(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;
} finally {
loading.value = false;
}
// Which manufacturer services the user has connected — it decides whether the
// connected-service tab appears. Fired separately and failure-tolerant: a
// plugin being down must not take the car page with it.
api
.listVehicleProviders()
.then((list) => (providers.value = list))
.catch(() => {});
}
function openAddService() {
editingService.value = null;
showService.value = true;
}
function openEditService(s) {
editingService.value = s;
showService.value = true;
}
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) {
if (!confirm(t("car.services.confirmDelete"))) return;
try {
await api.deleteService(id);
await load();
} catch (e) {
error.value = e.message;
}
}
// --- technical checks ---
function openAddTechnicalCheck() {
editingTechnicalCheck.value = null;
showTechnicalCheck.value = true;
}
function openEditTechnicalCheck(t) {
editingTechnicalCheck.value = t;
showTechnicalCheck.value = true;
}
async function onTechnicalCheckSaved() {
showTechnicalCheck.value = false;
editingTechnicalCheck.value = null;
await load();
}
async function deleteTechnicalCheck(id) {
if (!confirm(t("car.technical.confirmDelete"))) return;
try {
await api.deleteTechnicalCheck(id);
await load();
} catch (e) {
error.value = e.message;
}
}
function openAddPart() {
editingPart.value = null;
showPart.value = true;
}
function openEditPart(p) {
editingPart.value = p;
showPart.value = true;
}
async function onPartSaved() {
showPart.value = false;
editingPart.value = null;
parts.value = await api.listCarParts(props.id);
}
async function deletePart(id) {
if (!confirm(t("car.parts.confirmDelete"))) return;
try {
await api.deletePart(id);
parts.value = await api.listCarParts(props.id);
} catch (e) {
error.value = e.message;
}
}
// --- 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(t("car.fuel.confirmDelete"))) 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(t("car.maintenance.confirmDelete"))) 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(t("car.documents.confirmDelete"))) 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;
}
}
// --- attachments ---
// The file needs the auth header, so it is fetched as a Blob rather than linked
// to directly. `kind` names one of api.files — the same keys as the tabs.
async function downloadAttachment(kind, record) {
try {
const { blob, filename } = await api.files[kind].download(record.id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename || record.fileName || "attachment";
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(t("car.reminders.confirmDelete"))) 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;
}
// The provider panel writes to the car too — it links/unlinks the connected
// service and can take the odometer from it, which moves km-triggered reminders.
async function onCarUpdated(updated) {
car.value = updated;
reminders.value = await api.listCarReminders(props.id);
}
function openDeleteCar() {
deleteConfirmText.value = "";
showDeleteCar.value = true;
}
// Enabled only once the typed name matches — guards this cascade delete.
const canDeleteCar = computed(
() => car.value && deleteConfirmText.value.trim() === car.value.name
);
async function confirmDeleteCar() {
if (!canDeleteCar.value) return;
deletingCar.value = true;
error.value = "";
try {
await api.deleteCar(props.id);
router.push({ name: "dashboard" });
} catch (e) {
error.value = e.message;
deletingCar.value = false;
}
}
function yn(v) {
return v ? t("common.yes") : t("common.no");
}
// Enum → localized label. Each of these mirrors a block in enums.* of the
// language files; an unknown value falls through to the raw code (or an em dash
// for fuel, which is the "none set" case) rather than a missing-key marker.
function fuelLabel(v) {
return v ? t(`enums.fuelType.${v}`) : t("common.empty");
}
function maintenanceTypeLabel(v) {
return v ? t(`enums.maintenanceType.${v}`) : v;
}
function maintenanceStatusLabel(v) {
return v ? t(`enums.maintenanceStatus.${v}`) : v;
}
function documentTypeLabel(v) {
return v ? t(`enums.documentType.${v}`) : v;
}
function reminderTypeLabel(v) {
return v ? t(`enums.reminderTypeShort.${v}`) : v;
}
const MAINTENANCE_STATUS = {
scheduled: "dh-badge dh-badge-warning",
in_progress: "dh-badge dh-badge-warning",
completed: "dh-badge dh-badge-success",
};
onMounted(load);
</script>
<template>
<div>
<RouterLink to="/" class="mb-4 inline-flex items-center gap-1 text-sm font-medium text-brandtext hover:underline">
{{ t("car.allCars") }}
</RouterLink>
<p v-if="error" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ error }}</p>
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
<template v-else-if="car">
<!-- Header -->
<div class="dh-card mb-6 p-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="text-2xl font-bold tracking-[-0.03em] text-strong">{{ car.name }}</h1>
<p class="text-sm text-muted">
{{ [car.make, car.model, car.year || ''].filter(Boolean).join(' ') }}
<span v-if="car.registration"> · {{ car.registration }}</span>
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span v-if="!isOwner" class="dh-badge dh-badge-neutral">
{{ isReadOnly ? t("car.sharedReadOnly") : t("car.shared") }}
</span>
<span :class="status.classes">{{ status.label }}</span>
<!-- Which tabs this car's page shows. -->
<button
v-if="canWrite"
class="dh-btn dh-btn-ghost !px-2 !py-1.5"
:title="t('car.viewPicker.open')"
:aria-label="t('car.viewPicker.open')"
@click="openViewPicker"
>
<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="M9.6 3.6 9 6a7.5 7.5 0 0 0-1.7 1L5 6.3l-2 3.4 2 1.5a7.6 7.6 0 0 0 0 2l-2 1.5 2 3.4 2.3-.7c.5.4 1.1.8 1.7 1l.6 2.4h4l.6-2.4c.6-.2 1.2-.6 1.7-1l2.3.7 2-3.4-2-1.5a7.6 7.6 0 0 0 0-2l2-1.5-2-3.4-2.3.7A7.5 7.5 0 0 0 15 6l-.6-2.4z"/><circle cx="12" cy="12" r="2.6"/></svg>
</button>
<button v-if="isOwner" class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="showShare = true">{{ t("car.share") }}</button>
<button v-if="canWrite" class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="showCarEdit = true">{{ t("common.edit") }}</button>
<button v-if="isOwner" class="dh-btn !px-3 !py-1.5 border border-danger/30 text-danger hover:bg-danger-soft" @click="openDeleteCar">{{ t("common.delete") }}</button>
</div>
</div>
</div>
<!-- Tabs -->
<div class="mb-6 flex flex-wrap gap-1 border-b border-subtle">
<button
v-for="tab in TABS"
:key="tab.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 === tab.key
? 'border-accent text-brandtext'
: 'border-transparent text-muted hover:text-strong'"
@click="activeTab = tab.key">
{{ tab.label }}
<span
v-if="tab.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>
<!-- Connected service (MyToyota, …). Mounted only when its tab is open, so
opening a car never triggers a login against the manufacturer. -->
<ProviderPanel
v-if="activeTab === 'provider'"
:car="car"
:can-write="canWrite"
:provider-label="car.provider ? providerLabel : ''"
@car-updated="onCarUpdated"
/>
<!-- Information. The rows can be dragged into any order with write access;
the arrangement belongs to the car, like which rows show at all. -->
<section v-else-if="activeTab === 'info'">
<p v-if="fieldOrderError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ fieldOrderError }}</p>
<div class="dh-card p-6">
<p v-if="infoFields.length === 0" class="text-sm text-muted">{{ t("car.info.allHidden") }}</p>
<dl v-else class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div
v-for="f in infoFields"
:key="f.key"
:draggable="canArrangeFields"
:title="canArrangeFields ? t('car.info.dragHint') : ''"
class="rounded-control p-2 -m-2 transition-shadow duration-150"
:class="[
canArrangeFields ? 'cursor-grab hover:bg-sunken active:cursor-grabbing' : '',
dragField === f.key ? 'opacity-50' : '',
dropField === f.key ? 'ring-2 ring-accent' : '',
]"
@dragstart="onFieldDragStart(f.key, $event)"
@dragenter.prevent="onFieldDragEnter(f.key)"
@dragover.prevent
@drop.prevent="commitFieldOrder"
@dragend="commitFieldOrder"
>
<dt class="eyebrow">{{ f.label }}</dt>
<dd class="mt-0.5 font-medium text-strong" :class="f.mono ? 'data' : ''">{{ f.text }}</dd>
</div>
</dl>
</div>
<p v-if="canArrangeFields" class="mt-2 text-xs text-muted">{{ t("car.info.dragHint") }}</p>
</section>
<!-- Service history -->
<section v-else-if="activeTab === 'services'">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("car.services.title") }}</h2>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddService">
<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>
{{ t("car.services.add") }}
</button>
</div>
<div v-if="services.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
{{ t("car.services.empty") }}
</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>{{ t("car.services.colDate") }}</th>
<th>{{ t("car.services.colKm") }}</th>
<th>{{ t("car.services.colNextDate") }}</th>
<th>{{ t("car.services.colNextKm") }}</th>
<th class="!text-center">{{ t("car.services.colOil") }}</th>
<th class="!text-center">{{ t("car.services.colEngineFilter") }}</th>
<th class="!text-center">{{ t("car.services.colCabinFilter") }}</th>
<th>{{ t("car.services.colNotes") }}</th>
<th>{{ t("car.services.colFile") }}</th>
<th v-if="canWrite"></th>
</tr>
</thead>
<tbody class="divide-y divide-subtle">
<tr v-for="s in services" :key="s.id" class="transition-colors hover:bg-sunken">
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">{{ formatDate(s.date) }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ formatKm(s.km) }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ formatDate(s.nextServiceDate) }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ formatKm(s.nextServiceKm) }}</td>
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedOil ? 'text-success' : 'text-muted'">{{ yn(s.changedOil) }}</td>
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedEngineAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedEngineAirFilter) }}</td>
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedCabinAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedCabinAirFilter) }}</td>
<td class="px-4 py-3 text-body">{{ s.notes || t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="s.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('services', s)">
{{ t("common.download") }}
</button>
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditService(s)">{{ t("common.edit") }}</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteService(s.id)">{{ t("common.delete") }}</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Technical check history -->
<section v-else-if="activeTab === 'technical'">
<div class="mb-3 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("car.technical.title") }}</h2>
<p class="text-sm text-muted">{{ t("car.technical.subtitle") }}</p>
</div>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddTechnicalCheck">
<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>
{{ t("car.technical.add") }}
</button>
</div>
<div v-if="technicalChecks.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
{{ t("car.technical.empty") }}
</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>{{ t("car.technical.colDate") }}</th>
<th>{{ t("car.technical.colResult") }}</th>
<th>{{ t("car.technical.colNextCheck") }}</th>
<th>{{ t("car.technical.colStatus") }}</th>
<th>{{ t("car.technical.colStation") }}</th>
<th class="!text-right">{{ t("car.technical.colCost") }}</th>
<th>{{ t("car.technical.colNotes") }}</th>
<th>{{ t("car.technical.colFile") }}</th>
<th v-if="canWrite"></th>
</tr>
</thead>
<tbody class="divide-y divide-subtle">
<tr v-for="tc in technicalChecks" :key="tc.id" class="transition-colors hover:bg-sunken">
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">{{ formatDate(tc.date) }}</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="tc.result === 'failed' ? 'dh-badge dh-badge-danger' : 'dh-badge dh-badge-success'">
{{ tc.result === 'failed' ? t("car.technical.failed") : t("car.technical.passed") }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ formatDate(tc.nextCheckDate) }}</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="expiryStatus(tc).classes">{{ expiryStatus(tc).label }}</span>
</td>
<td class="px-4 py-3 text-body">{{ tc.station || t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">{{ tc.cost ? formatMoney(tc.cost) : t("common.empty") }}</td>
<td class="px-4 py-3 text-body">{{ tc.notes || t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="tc.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('technical', tc)">
{{ t("common.download") }}
</button>
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</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="openEditTechnicalCheck(tc)">{{ t("common.edit") }}</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteTechnicalCheck(tc.id)">{{ t("common.delete") }}</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Maintenance -->
<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">{{ t("car.maintenance.title") }}</h2>
<p class="text-sm text-muted">{{ t("car.maintenance.subtitle") }}</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>
{{ t("car.maintenance.add") }}
</button>
</div>
<div v-if="maintenance.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
{{ t("car.maintenance.empty") }}
</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>{{ t("car.maintenance.colDate") }}</th>
<th>{{ t("car.maintenance.colKm") }}</th>
<th>{{ t("car.maintenance.colType") }}</th>
<th>{{ t("car.maintenance.colWork") }}</th>
<th>{{ t("car.maintenance.colWorkshop") }}</th>
<th>{{ t("car.maintenance.colStatus") }}</th>
<th class="!text-right">{{ t("car.maintenance.colCost") }}</th>
<th>{{ t("car.maintenance.colFile") }}</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">{{ maintenanceTypeLabel(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">
{{ t("car.maintenance.underWarranty", { days: m.warrantyDaysLeft }) }}
</div>
</td>
<td class="px-4 py-3 text-body">
{{ m.workshop || t("common.empty") }}
<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'">
{{ maintenanceStatusLabel(m.status) }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">
{{ m.totalCost ? formatMoney(m.totalCost) : t("common.empty") }}
</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="m.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('maintenance', m)">
{{ t("common.download") }}
</button>
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditMaintenance(m)">{{ t("common.edit") }}</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteMaintenance(m.id)">{{ t("common.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">{{ t("car.fuel.title") }}</h2>
<p class="text-sm text-muted">{{ t("car.fuel.subtitle") }}</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>
{{ t("car.fuel.add") }}
</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">{{ t("car.fuel.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">{{ t("car.fuel.best") }}</dt>
<dd class="mt-0.5 data font-medium text-success">{{ formatConsumption(fuelStats.bestConsumptionL100) }}</dd>
</div>
<div>
<dt class="eyebrow">{{ t("car.fuel.worst") }}</dt>
<dd class="mt-0.5 data font-medium text-danger">{{ formatConsumption(fuelStats.worstConsumptionL100) }}</dd>
</div>
<div>
<dt class="eyebrow">{{ t("car.fuel.costPerKm") }}</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ formatMoney(fuelStats.costPerKm) }}</dd>
</div>
<div>
<dt class="eyebrow">{{ t("car.fuel.refills") }}</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ fuelStats.entries }}</dd>
</div>
<div>
<dt class="eyebrow">{{ t("car.fuel.totalLiters") }}</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ formatLiters(fuelStats.totalLiters) }}</dd>
</div>
<div>
<dt class="eyebrow">{{ t("car.fuel.totalSpent") }}</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ formatMoney(fuelStats.totalCost) }}</dd>
</div>
<div>
<dt class="eyebrow">{{ t("car.fuel.trackedDistance") }}</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ formatKm(fuelStats.trackedDistanceKm) }}</dd>
<dd class="text-xs text-muted">{{ t("car.fuel.avgPrice", { price: formatMoney(fuelStats.avgPricePerLiter) }) }}</dd>
</div>
</dl>
<p v-if="!fuelStats.avgConsumptionL100" class="mt-4 text-xs text-muted">
{{ t("car.fuel.needTwoTanks") }}
</p>
</div>
<div v-if="fuel.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
{{ t("car.fuel.empty") }}
</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>{{ t("car.fuel.colDate") }}</th>
<th>{{ t("car.fuel.colKm") }}</th>
<th class="!text-right">{{ t("car.fuel.colLiters") }}</th>
<th class="!text-right">{{ t("car.fuel.colCost") }}</th>
<th class="!text-right">{{ t("car.fuel.colPerLiter") }}</th>
<th class="!text-right">{{ t("car.fuel.colDistance") }}</th>
<th class="!text-right">{{ t("car.fuel.colConsumption") }}</th>
<th>{{ t("car.fuel.colStation") }}</th>
<th>{{ t("car.fuel.colFile") }}</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">{{ t("car.fuel.partial") }}</span>
<span v-if="f.missedFill" class="ml-1 text-xs font-normal text-warning">{{ t("car.fuel.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) : t("common.empty") }}</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) : t("common.empty") }}</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 || t("common.empty") }}
<div v-if="f.notes" class="text-xs text-muted">{{ f.notes }}</div>
</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="f.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('fuel', f)">
{{ t("common.download") }}
</button>
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditFuel(f)">{{ t("common.edit") }}</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteFuel(f.id)">{{ t("common.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">{{ t("car.documents.title") }}</h2>
<p class="text-sm text-muted">{{ t("car.documents.subtitle") }}</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>
{{ t("car.documents.add") }}
</button>
</div>
<div v-if="documents.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
{{ t("car.documents.empty") }}
</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>{{ t("car.documents.colType") }}</th>
<th>{{ t("car.documents.colTitle") }}</th>
<th>{{ t("car.documents.colProvider") }}</th>
<th>{{ t("car.documents.colIssued") }}</th>
<th>{{ t("car.documents.colRenewal") }}</th>
<th>{{ t("car.documents.colStatus") }}</th>
<th>{{ t("car.documents.colFile") }}</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">{{ documentTypeLabel(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 || t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ d.issueDate ? formatDate(d.issueDate) : t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ d.expiryDate ? formatDate(d.expiryDate) : t("common.empty") }}</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="downloadAttachment('documents', d)">
{{ t("common.download") }}
</button>
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</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)">{{ t("common.edit") }}</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteDocument(d.id)">{{ t("common.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">{{ t("car.reminders.title") }}</h2>
<p class="text-sm text-muted">{{ t("car.reminders.subtitle") }}</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>
{{ t("car.reminders.add") }}
</button>
</div>
<div v-if="reminders.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
{{ t("car.reminders.empty") }}
</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">{{ reminderTypeLabel(r.type) }}</span>
<span v-if="r.auto" class="dh-badge dh-badge-neutral">{{ t("car.reminders.automatic") }}</span>
<span v-if="r.repeatDays || r.repeatKm" class="dh-badge dh-badge-neutral">{{ t("car.reminders.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">{{ t("car.reminders.at", { km: 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 ? t("car.reminders.doneRollForward") : t("car.reminders.markDone") }}
</button>
<button v-else class="text-xs font-medium text-brandtext hover:underline" @click="reopenReminder(r)">{{ t("car.reminders.reopen") }}</button>
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditReminder(r)">{{ t("common.edit") }}</button>
<button class="text-xs font-medium text-danger hover:underline" @click="deleteReminder(r.id)">{{ t("common.delete") }}</button>
</template>
</div>
</li>
</ul>
</section>
<!-- Parts catalog -->
<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">{{ t("car.parts.title") }}</h2>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddPart">
<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>
{{ t("car.parts.add") }}
</button>
</div>
<div v-if="parts.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
{{ t("car.parts.empty") }}
</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>{{ t("car.parts.colPart") }}</th>
<th>{{ t("car.parts.colPartNumber") }}</th>
<th>{{ t("car.parts.colNotes") }}</th>
<th>{{ t("car.parts.colFile") }}</th>
<th v-if="canWrite"></th>
</tr>
</thead>
<tbody class="divide-y divide-subtle">
<tr v-for="p in parts" :key="p.id" class="transition-colors hover:bg-sunken">
<td class="px-4 py-3 font-medium text-strong">{{ p.name }}</td>
<td class="px-4 py-3 data text-body">{{ p.partNumber || t("common.empty") }}</td>
<td class="px-4 py-3 text-body">{{ p.notes || t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="p.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('parts', p)">
{{ t("common.download") }}
</button>
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditPart(p)">{{ t("common.edit") }}</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deletePart(p.id)">{{ t("common.delete") }}</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<!-- Modals -->
<CarFormModal v-if="showCarEdit" :car="car" @saved="onCarSaved" @close="showCarEdit = false" />
<ServiceFormModal
v-if="showService"
:car-id="id"
:car="car"
:service="editingService"
@saved="onServiceSaved"
@close="showService = false"
/>
<TechnicalCheckFormModal
v-if="showTechnicalCheck"
:car-id="id"
:car="car"
:check="editingTechnicalCheck"
@saved="onTechnicalCheckSaved"
@close="showTechnicalCheck = false"
/>
<PartFormModal
v-if="showPart"
:car-id="id"
:part="editingPart"
@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" />
<!-- What this car's page shows: tabs, and the Information rows -->
<Modal v-if="showViewPicker" :title="t('car.viewPicker.title')" @close="showViewPicker = false">
<p class="mb-4 text-sm text-muted">{{ t("car.viewPicker.subtitle") }}</p>
<p class="eyebrow mb-2">{{ t("car.viewPicker.tabsHeading") }}</p>
<div class="grid gap-2 sm:grid-cols-2">
<label
v-for="key in tabPickerKeys"
:key="key"
class="flex items-center gap-2 text-sm font-medium text-body"
>
<input
type="checkbox"
class="h-4 w-4 rounded border-subtle text-accent focus:ring-accent"
:checked="tabDraft.includes(key)"
@change="toggleTabDraft(key, $event.target.checked)"
/>
<span>{{ tabPickerLabel(key) }}</span>
</label>
</div>
<p class="mt-2 text-xs text-muted">{{ t("car.viewPicker.alwaysOn", { tab: t("car.tabs.info") }) }}</p>
<p class="eyebrow mb-2 mt-5">{{ t("car.viewPicker.fieldsHeading") }}</p>
<div class="grid gap-2 sm:grid-cols-2">
<label
v-for="key in fieldKeys"
:key="key"
class="flex items-center gap-2 text-sm font-medium text-body"
>
<input
type="checkbox"
class="h-4 w-4 rounded border-subtle text-accent focus:ring-accent"
:checked="fieldDraft.includes(key)"
@change="toggleFieldDraft(key, $event.target.checked)"
/>
<span>{{ infoFieldLabel(key) }}</span>
</label>
</div>
<p class="mt-2 text-xs text-muted">{{ t("car.viewPicker.fieldsOrderHint") }}</p>
<p v-if="viewError" class="mt-3 text-sm text-danger">{{ viewError }}</p>
<div class="mt-5 flex justify-end gap-2">
<button class="dh-btn dh-btn-ghost" @click="showViewPicker = false">{{ t("common.cancel") }}</button>
<button class="dh-btn dh-btn-primary" :disabled="viewSaving" @click="saveView">
{{ viewSaving ? t("common.saving") : t("common.save") }}
</button>
</div>
</Modal>
<!-- Delete-car confirmation (type-to-confirm; cascade removes all data) -->
<div v-if="showDeleteCar && car" class="fixed inset-0 z-30 grid place-items-center bg-brand-900/40 p-4 backdrop-blur-sm" @click.self="showDeleteCar = false">
<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">{{ t("car.delete.title") }}</h2>
<p class="mb-4 text-sm text-body">
{{ t("car.delete.body", {
name: car.name,
services: t("car.delete.services", { n: services.length }),
maintenance: t("car.delete.maintenance", { n: maintenance.length }),
fuel: t("car.delete.fuel", { n: fuel.length }),
documents: t("car.delete.documents", { n: documents.length }),
parts: t("car.delete.parts", { n: parts.length }),
}) }}
</p>
<label class="dh-label">
{{ tSplit("car.delete.typeToConfirm", "name").before
}}<span class="data text-strong">{{ car.name }}</span>{{ tSplit("car.delete.typeToConfirm", "name").after }}
</label>
<input v-model="deleteConfirmText" :placeholder="car.name" class="dh-input mb-4" />
<div class="flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="showDeleteCar = false">{{ t("common.cancel") }}</button>
<button type="button" :disabled="!canDeleteCar || deletingCar" class="dh-btn dh-btn-danger" @click="confirmDeleteCar">
{{ deletingCar ? t("car.delete.deleting") : t("car.delete.confirm") }}
</button>
</div>
</div>
</div>
</div>
</template>