Add a language-switch system with per-language files
Introduce a hand-rolled i18n layer across all three UIs, each reading its
text from per-language JSON files (English base + Polish + Danish). Nothing
in the converted screens hardcodes English any more.
- Web App (Vue): src/i18n/{en,pl,da}.json + index.js exposing t()/tSplit(),
reactive to the signed-in profile locale. Every view, component, form and
the status labels in lib/format.js go through t().
- API Server panel (Vue): src/i18n/ with its own localStorage-persisted
language (the panel has no user profile) and a header language picker.
Chrome, cards, login and API section titles translated; endpoint reference
descriptions intentionally kept in English. Rebuilt embedded dist.
- Phone App (Flutter): assets/i18n/ + lib/i18n.dart loaded at startup,
driven by AppSettings.locale. Nav, login, lock, dashboard, the full
Settings panel (incl. language picker) and format.dart status labels
translated; remaining detail screens fall back to English.
Language = the language half of the existing BCP-47 locale; the region half
still drives date/number/currency formatting. Missing keys fall back to
English, and plurals use Intl.PluralRules / Intl.plural so Polish gets the
correct one/few/many forms. Settings flags languages without a translation.
Tests updated to assert the localized (Polish) status wording; all pass.
See TRANSLATIONS.md for the format and how to add a language.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ee28b522c7
commit
b6bb6b1df0
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { api } from "../api";
|
||||
import { state } from "../auth";
|
||||
import { formatDate } from "../lib/format.js";
|
||||
import { t } from "../i18n";
|
||||
import Modal from "../components/Modal.vue";
|
||||
|
||||
const users = ref([]);
|
||||
@@ -35,13 +36,13 @@ const assignableRoles = computed(() =>
|
||||
// reason. These mirror the server's guards so the UI doesn't offer an action
|
||||
// that is going to come back as a 400/403.
|
||||
function deleteBlockedReason(u) {
|
||||
if (u.id === myId) return "You can't delete your own account.";
|
||||
if (u.role === "superadmin" && !isSuperadmin.value) return "Only a superadmin can delete a superadmin.";
|
||||
if (u.id === myId) return t("admin.cantDeleteSelf");
|
||||
if (u.role === "superadmin" && !isSuperadmin.value) return t("admin.onlySuperadminDeletes");
|
||||
return "";
|
||||
}
|
||||
function roleLockReason(u) {
|
||||
if (u.id === myId) return "You can't change your own role.";
|
||||
if (u.role === "superadmin" && !isSuperadmin.value) return "Only a superadmin can edit a superadmin.";
|
||||
if (u.id === myId) return t("admin.cantChangeOwnRole");
|
||||
if (u.role === "superadmin" && !isSuperadmin.value) return t("admin.onlySuperadminEdits");
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -109,7 +110,7 @@ async function submitResetPassword() {
|
||||
}
|
||||
|
||||
async function removeUser(u) {
|
||||
if (!confirm(`Delete ${u.name || u.email}? This cannot be undone.`)) return;
|
||||
if (!confirm(t("admin.confirmDelete", { name: u.name || u.email }))) return;
|
||||
error.value = "";
|
||||
try {
|
||||
await api.deleteUser(u.id);
|
||||
@@ -126,31 +127,31 @@ onMounted(load);
|
||||
<div>
|
||||
<div class="mb-6 flex items-end justify-between">
|
||||
<div>
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Users</h1>
|
||||
<p class="eyebrow">{{ t("admin.eyebrow") }}</p>
|
||||
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("admin.title") }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
{{ isSuperadmin ? "Accounts across every organization." : "Accounts in your organization." }}
|
||||
Organizations are assigned in the API panel.
|
||||
{{ isSuperadmin ? t("admin.subtitleAll") : t("admin.subtitleOrg") }}
|
||||
{{ t("admin.subtitleOrgsNote") }}
|
||||
</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-primary" @click="showCreate = true">
|
||||
<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 user
|
||||
{{ t("admin.addUser") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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">Loading…</p>
|
||||
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
|
||||
|
||||
<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>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Organization</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th>{{ t("admin.colEmail") }}</th>
|
||||
<th>{{ t("admin.colName") }}</th>
|
||||
<th>{{ t("admin.colOrganization") }}</th>
|
||||
<th>{{ t("admin.colRole") }}</th>
|
||||
<th>{{ t("admin.colCreated") }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -158,10 +159,10 @@ onMounted(load);
|
||||
<tr v-for="u in users" :key="u.id" class="transition-colors hover:bg-sunken">
|
||||
<td class="px-4 py-3 font-medium text-strong">
|
||||
{{ u.email }}
|
||||
<span v-if="u.id === myId" class="ml-1 text-xs text-muted">(you)</span>
|
||||
<span v-if="u.id === myId" class="ml-1 text-xs text-muted">{{ t("admin.you") }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-body">{{ u.name || '—' }}</td>
|
||||
<td class="px-4 py-3 text-body">{{ u.organizationName || '—' }}</td>
|
||||
<td class="px-4 py-3 text-body">{{ u.name || t("common.empty") }}</td>
|
||||
<td class="px-4 py-3 text-body">{{ u.organizationName || t("common.empty") }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<select
|
||||
:value="u.role"
|
||||
@@ -170,21 +171,21 @@ onMounted(load);
|
||||
class="dh-input w-auto !py-1 !text-xs disabled:opacity-60"
|
||||
@change="changeRole(u, $event.target.value)"
|
||||
>
|
||||
<option v-for="r in assignableRoles" :key="r" :value="r">{{ r }}</option>
|
||||
<option v-for="r in assignableRoles" :key="r" :value="r">{{ t(`admin.roles.${r}`) }}</option>
|
||||
<!-- Keep the current role selectable even when this viewer can't assign it. -->
|
||||
<option v-if="!assignableRoles.includes(u.role)" :value="u.role">{{ u.role }}</option>
|
||||
<option v-if="!assignableRoles.includes(u.role)" :value="u.role">{{ t(`admin.roles.${u.role}`) }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-3 data text-muted">{{ formatDate(u.created) }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<button class="text-xs font-medium text-brandtext hover:underline" @click="openResetPassword(u)">Reset password</button>
|
||||
<button class="text-xs font-medium text-brandtext hover:underline" @click="openResetPassword(u)">{{ t("admin.resetPassword") }}</button>
|
||||
<button
|
||||
class="ml-3 text-xs font-medium text-danger hover:underline disabled:cursor-not-allowed disabled:text-muted disabled:no-underline"
|
||||
:disabled="!!deleteBlockedReason(u)"
|
||||
:title="deleteBlockedReason(u)"
|
||||
@click="removeUser(u)"
|
||||
>
|
||||
Delete
|
||||
{{ t("common.delete") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -193,50 +194,50 @@ onMounted(load);
|
||||
</div>
|
||||
|
||||
<!-- Create user -->
|
||||
<Modal v-if="showCreate" title="Add a user" @close="showCreate = false">
|
||||
<Modal v-if="showCreate" :title="t('admin.createTitle')" @close="showCreate = false">
|
||||
<p v-if="createError" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ createError }}</p>
|
||||
<form class="space-y-3" @submit.prevent="submitCreate">
|
||||
<div>
|
||||
<label class="dh-label">Email *</label>
|
||||
<label class="dh-label">{{ t("admin.emailRequired") }}</label>
|
||||
<input v-model="createForm.email" type="email" required autocomplete="off" class="dh-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Name</label>
|
||||
<label class="dh-label">{{ t("admin.colName") }}</label>
|
||||
<input v-model="createForm.name" autocomplete="off" class="dh-input" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="dh-label">Password * <span class="text-muted">(min 8)</span></label>
|
||||
<label class="dh-label">{{ t("admin.passwordRequired") }} <span class="text-muted">{{ t("admin.minChars") }}</span></label>
|
||||
<input v-model="createForm.password" type="text" required minlength="8" autocomplete="new-password" class="dh-input data" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Role</label>
|
||||
<label class="dh-label">{{ t("admin.colRole") }}</label>
|
||||
<select v-model="createForm.role" class="dh-input">
|
||||
<option v-for="r in assignableRoles" :key="r" :value="r">{{ r }}</option>
|
||||
<option v-for="r in assignableRoles" :key="r" :value="r">{{ t(`admin.roles.${r}`) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button type="button" class="dh-btn dh-btn-ghost" @click="showCreate = false">Cancel</button>
|
||||
<button type="button" class="dh-btn dh-btn-ghost" @click="showCreate = false">{{ t("common.cancel") }}</button>
|
||||
<button type="submit" :disabled="creating" class="dh-btn dh-btn-primary">
|
||||
{{ creating ? "Creating…" : "Create user" }}
|
||||
{{ creating ? t("admin.creating") : t("admin.createUser") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Reset password -->
|
||||
<Modal v-if="pwUser" :title="`Reset password — ${pwUser.email}`" @close="pwUser = null">
|
||||
<Modal v-if="pwUser" :title="t('admin.resetTitle', { email: pwUser.email })" @close="pwUser = null">
|
||||
<p v-if="pwError" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ pwError }}</p>
|
||||
<form class="space-y-3" @submit.prevent="submitResetPassword">
|
||||
<div>
|
||||
<label class="dh-label">New password <span class="text-muted">(min 8)</span></label>
|
||||
<label class="dh-label">{{ t("admin.newPassword") }} <span class="text-muted">{{ t("admin.minChars") }}</span></label>
|
||||
<input v-model="newPassword" type="text" required minlength="8" autocomplete="new-password" class="dh-input data" />
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button type="button" class="dh-btn dh-btn-ghost" @click="pwUser = null">Cancel</button>
|
||||
<button type="button" class="dh-btn dh-btn-ghost" @click="pwUser = null">{{ t("common.cancel") }}</button>
|
||||
<button type="submit" :disabled="savingPw" class="dh-btn dh-btn-primary">
|
||||
{{ savingPw ? "Saving…" : "Set password" }}
|
||||
{{ savingPw ? t("common.saving") : t("admin.setPassword") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+216
-239
@@ -13,6 +13,7 @@ import {
|
||||
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";
|
||||
@@ -81,16 +82,18 @@ 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: "technical", label: "Technical check history" },
|
||||
{ key: "maintenance", label: "Maintenance" },
|
||||
{ key: "fuel", label: "Fuel" },
|
||||
{ key: "documents", label: "Documents" },
|
||||
{ key: "parts", label: "Parts catalog" },
|
||||
{ key: "reminders", label: "Reminders" },
|
||||
];
|
||||
// Computed, not a plain array: t() reads the reactive locale, so the tab labels
|
||||
// have to re-evaluate when the language changes.
|
||||
const TABS = computed(() => [
|
||||
{ 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") },
|
||||
]);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
@@ -139,7 +142,7 @@ async function onServiceSaved() {
|
||||
await load();
|
||||
}
|
||||
async function deleteService(id) {
|
||||
if (!confirm("Delete this service record?")) return;
|
||||
if (!confirm(t("car.services.confirmDelete"))) return;
|
||||
try {
|
||||
await api.deleteService(id);
|
||||
await load();
|
||||
@@ -163,7 +166,7 @@ async function onTechnicalCheckSaved() {
|
||||
await load();
|
||||
}
|
||||
async function deleteTechnicalCheck(id) {
|
||||
if (!confirm("Delete this technical check?")) return;
|
||||
if (!confirm(t("car.technical.confirmDelete"))) return;
|
||||
try {
|
||||
await api.deleteTechnicalCheck(id);
|
||||
await load();
|
||||
@@ -186,7 +189,7 @@ async function onPartSaved() {
|
||||
parts.value = await api.listCarParts(props.id);
|
||||
}
|
||||
async function deletePart(id) {
|
||||
if (!confirm("Delete this part?")) return;
|
||||
if (!confirm(t("car.parts.confirmDelete"))) return;
|
||||
try {
|
||||
await api.deletePart(id);
|
||||
parts.value = await api.listCarParts(props.id);
|
||||
@@ -221,7 +224,7 @@ async function onFuelSaved() {
|
||||
await reloadFuel();
|
||||
}
|
||||
async function deleteFuel(id) {
|
||||
if (!confirm("Delete this refill?")) return;
|
||||
if (!confirm(t("car.fuel.confirmDelete"))) return;
|
||||
try {
|
||||
await api.deleteFuel(id);
|
||||
await reloadFuel();
|
||||
@@ -250,7 +253,7 @@ async function onMaintenanceSaved() {
|
||||
]);
|
||||
}
|
||||
async function deleteMaintenance(id) {
|
||||
if (!confirm("Delete this workshop visit?")) return;
|
||||
if (!confirm(t("car.maintenance.confirmDelete"))) return;
|
||||
try {
|
||||
await api.deleteMaintenance(id);
|
||||
maintenance.value = await api.listCarMaintenance(props.id);
|
||||
@@ -278,7 +281,7 @@ async function onDocumentSaved() {
|
||||
]);
|
||||
}
|
||||
async function deleteDocument(id) {
|
||||
if (!confirm("Delete this document?")) return;
|
||||
if (!confirm(t("car.documents.confirmDelete"))) return;
|
||||
try {
|
||||
await api.deleteDocument(id);
|
||||
[documents.value, reminders.value] = await Promise.all([
|
||||
@@ -347,7 +350,7 @@ async function reopenReminder(r) {
|
||||
}
|
||||
}
|
||||
async function deleteReminder(id) {
|
||||
if (!confirm("Delete this reminder?")) return;
|
||||
if (!confirm(t("car.reminders.confirmDelete"))) return;
|
||||
try {
|
||||
await api.deleteReminder(id);
|
||||
reminders.value = await api.listCarReminders(props.id);
|
||||
@@ -385,60 +388,33 @@ async function confirmDeleteCar() {
|
||||
}
|
||||
|
||||
function yn(v) {
|
||||
return v ? "Yes" : "No";
|
||||
return v ? t("common.yes") : t("common.no");
|
||||
}
|
||||
|
||||
const FUEL_LABELS = {
|
||||
petrol: "Petrol (gasoline)",
|
||||
petrol_lpg: "Petrol (gasoline) + LPG",
|
||||
diesel: "Diesel",
|
||||
diesel_lpg: "Diesel + LPG",
|
||||
hybrid: "Hybrid",
|
||||
electric: "Electric",
|
||||
hydrogen: "Hydrogen",
|
||||
};
|
||||
// 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 FUEL_LABELS[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_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>
|
||||
@@ -446,11 +422,11 @@ onMounted(load);
|
||||
<template>
|
||||
<div>
|
||||
<RouterLink to="/" class="mb-4 inline-flex items-center gap-1 text-sm font-medium text-brandtext hover:underline">
|
||||
← All cars
|
||||
{{ 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">Loading…</p>
|
||||
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
|
||||
|
||||
<template v-else-if="car">
|
||||
<!-- Header -->
|
||||
@@ -465,12 +441,12 @@ onMounted(load);
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="!isOwner" class="dh-badge dh-badge-neutral">
|
||||
Shared{{ isReadOnly ? ' · read-only' : '' }}
|
||||
{{ isReadOnly ? t("car.sharedReadOnly") : t("car.shared") }}
|
||||
</span>
|
||||
<span :class="status.classes">{{ status.label }}</span>
|
||||
<button v-if="isOwner" class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="showShare = true">Share</button>
|
||||
<button v-if="canWrite" class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="showCarEdit = true">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">Delete</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>
|
||||
@@ -478,16 +454,16 @@ onMounted(load);
|
||||
<!-- Tabs -->
|
||||
<div class="mb-6 flex flex-wrap gap-1 border-b border-subtle">
|
||||
<button
|
||||
v-for="t in TABS"
|
||||
:key="t.key"
|
||||
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 === t.key
|
||||
:class="activeTab === tab.key
|
||||
? 'border-accent text-brandtext'
|
||||
: 'border-transparent text-muted hover:text-strong'"
|
||||
@click="activeTab = t.key">
|
||||
{{ t.label }}
|
||||
@click="activeTab = tab.key">
|
||||
{{ tab.label }}
|
||||
<span
|
||||
v-if="t.key === 'reminders' && dueReminders"
|
||||
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>
|
||||
@@ -498,20 +474,20 @@ onMounted(load);
|
||||
<section v-if="activeTab === 'info'">
|
||||
<div class="dh-card p-6">
|
||||
<dl class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||
<div><dt class="eyebrow">Engine oil spec</dt><dd class="mt-0.5 font-medium text-strong">{{ car.oilSpec || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Transmission oil</dt><dd class="mt-0.5 font-medium text-strong">{{ car.transmissionOilSpec || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Differential oil</dt><dd class="mt-0.5 font-medium text-strong">{{ car.differentialOilSpec || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Brake fluid</dt><dd class="mt-0.5 font-medium text-strong">{{ car.brakeFluidSpec || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Coolant</dt><dd class="mt-0.5 font-medium text-strong">{{ car.coolantSpec || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Odometer</dt><dd class="mt-0.5 data font-medium text-strong">{{ formatKm(car.currentKm) }}</dd></div>
|
||||
<div><dt class="eyebrow">Service interval</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.serviceIntervalDays }}d · {{ formatKm(car.serviceIntervalKm) }}</dd></div>
|
||||
<div><dt class="eyebrow">Next due</dt><dd class="mt-0.5 data font-medium text-strong">{{ formatDate(latest?.nextServiceDate) }} · {{ formatKm(latest?.nextServiceKm) }}</dd></div>
|
||||
<div><dt class="eyebrow">Registration plate</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.registration || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Registration country</dt><dd class="mt-0.5 font-medium text-strong">{{ car.registrationCountry || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">VIN</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.vin || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Fuel type</dt><dd class="mt-0.5 font-medium text-strong">{{ fuelLabel(car.fuelType) }}</dd></div>
|
||||
<div><dt class="eyebrow">Build date</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.buildDate ? formatDate(car.buildDate) : '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">First registration</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.firstRegistrationDate ? formatDate(car.firstRegistrationDate) : '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.oilSpec") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ car.oilSpec || t("common.empty") }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.transmissionOil") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ car.transmissionOilSpec || t("common.empty") }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.differentialOil") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ car.differentialOilSpec || t("common.empty") }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.brakeFluid") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ car.brakeFluidSpec || t("common.empty") }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.coolant") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ car.coolantSpec || t("common.empty") }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.odometer") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ formatKm(car.currentKm) }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.serviceInterval") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.serviceIntervalDays }}d · {{ formatKm(car.serviceIntervalKm) }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.nextDue") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ formatDate(latest?.nextServiceDate) }} · {{ formatKm(latest?.nextServiceKm) }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.registrationPlate") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.registration || t("common.empty") }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.registrationCountry") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ car.registrationCountry || t("common.empty") }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.vin") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.vin || t("common.empty") }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.fuelType") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ fuelLabel(car.fuelType) }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.buildDate") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.buildDate ? formatDate(car.buildDate) : t("common.empty") }}</dd></div>
|
||||
<div><dt class="eyebrow">{{ t("car.info.firstRegistration") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.firstRegistrationDate ? formatDate(car.firstRegistrationDate) : t("common.empty") }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
@@ -519,30 +495,30 @@ onMounted(load);
|
||||
<!-- 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">Service history</h2>
|
||||
<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>
|
||||
Add service
|
||||
{{ 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">
|
||||
No service records yet.
|
||||
{{ 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>Date</th>
|
||||
<th>Km</th>
|
||||
<th>Next date</th>
|
||||
<th>Next km</th>
|
||||
<th class="!text-center">Oil & Oil filter</th>
|
||||
<th class="!text-center">Engine air filter</th>
|
||||
<th class="!text-center">Cabin air filter</th>
|
||||
<th>Notes</th>
|
||||
<th>File</th>
|
||||
<th>{{ 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>
|
||||
@@ -555,16 +531,16 @@ onMounted(load);
|
||||
<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 || '—' }}</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)">
|
||||
Download
|
||||
{{ t("common.download") }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
<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)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteService(s.id)">Delete</button>
|
||||
<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>
|
||||
@@ -576,60 +552,58 @@ onMounted(load);
|
||||
<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">Technical check history</h2>
|
||||
<p class="text-sm text-muted">
|
||||
Mandatory roadworthiness inspections. Recurs on time alone, whatever the odometer reads.
|
||||
</p>
|
||||
<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>
|
||||
Add check
|
||||
{{ 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">
|
||||
No technical checks yet.
|
||||
{{ 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>Date</th>
|
||||
<th>Result</th>
|
||||
<th>Next check</th>
|
||||
<th>Status</th>
|
||||
<th>Station</th>
|
||||
<th class="!text-right">Cost</th>
|
||||
<th>Notes</th>
|
||||
<th>File</th>
|
||||
<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="t in technicalChecks" :key="t.id" class="transition-colors hover:bg-sunken">
|
||||
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">{{ formatDate(t.date) }}</td>
|
||||
<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="t.result === 'failed' ? 'dh-badge dh-badge-danger' : 'dh-badge dh-badge-success'">
|
||||
{{ t.result === 'failed' ? 'Failed' : 'Passed' }}
|
||||
<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(t.nextCheckDate) }}</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(t).classes">{{ expiryStatus(t).label }}</span>
|
||||
<span :class="expiryStatus(tc).classes">{{ expiryStatus(tc).label }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-body">{{ t.station || '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">{{ t.cost ? formatMoney(t.cost) : '—' }}</td>
|
||||
<td class="px-4 py-3 text-body">{{ t.notes || '—' }}</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="t.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('technical', t)">
|
||||
Download
|
||||
<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">—</span>
|
||||
<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(t)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteTechnicalCheck(t.id)">Delete</button>
|
||||
<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>
|
||||
@@ -641,31 +615,31 @@ onMounted(load);
|
||||
<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</h2>
|
||||
<p class="text-sm text-muted">Workshop visits and repairs. Routine servicing lives under Service history.</p>
|
||||
<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>
|
||||
Log visit
|
||||
{{ 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">
|
||||
No workshop visits logged yet.
|
||||
{{ 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>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>File</th>
|
||||
<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>
|
||||
@@ -673,35 +647,35 @@ onMounted(load);
|
||||
<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="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">
|
||||
Under warranty · {{ m.warrantyDaysLeft }}d left
|
||||
{{ t("car.maintenance.underWarranty", { days: m.warrantyDaysLeft }) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-body">
|
||||
{{ m.workshop || '—' }}
|
||||
{{ 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'">
|
||||
{{ MAINTENANCE_STATUS_LABELS[m.status] || m.status }}
|
||||
{{ maintenanceStatusLabel(m.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">
|
||||
{{ m.totalCost ? formatMoney(m.totalCost) : '—' }}
|
||||
{{ 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)">
|
||||
Download
|
||||
{{ t("common.download") }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
<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)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteMaintenance(m.id)">Delete</button>
|
||||
<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>
|
||||
@@ -713,12 +687,12 @@ onMounted(load);
|
||||
<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>
|
||||
<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>
|
||||
Log refill
|
||||
{{ t("car.fuel.add") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -726,62 +700,62 @@ onMounted(load);
|
||||
<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>
|
||||
<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">Best</dt>
|
||||
<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">Worst</dt>
|
||||
<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">Cost per km</dt>
|
||||
<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">Refills</dt>
|
||||
<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">Total litres</dt>
|
||||
<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">Total spent</dt>
|
||||
<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">Tracked distance</dt>
|
||||
<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">Avg. price {{ formatMoney(fuelStats.avgPricePerLiter) }}/L</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">
|
||||
Log at least two full tanks to see consumption figures.
|
||||
{{ 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">
|
||||
No refills logged yet.
|
||||
{{ 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>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>File</th>
|
||||
<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>
|
||||
@@ -789,30 +763,30 @@ onMounted(load);
|
||||
<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>
|
||||
<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) : '—' }}</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) : '—' }}</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 || '—' }}
|
||||
{{ 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)">
|
||||
Download
|
||||
{{ t("common.download") }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
<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)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteFuel(f.id)">Delete</button>
|
||||
<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>
|
||||
@@ -824,55 +798,55 @@ onMounted(load);
|
||||
<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>
|
||||
<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>
|
||||
Add document
|
||||
{{ 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">
|
||||
No documents yet.
|
||||
{{ 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>Type</th>
|
||||
<th>Title</th>
|
||||
<th>Provider</th>
|
||||
<th>Issued</th>
|
||||
<th>Renewal</th>
|
||||
<th>Status</th>
|
||||
<th>File</th>
|
||||
<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">{{ DOCUMENT_LABELS[d.type] || d.type }}</td>
|
||||
<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 || '—' }}</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="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)">
|
||||
Download
|
||||
{{ t("common.download") }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
<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)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteDocument(d.id)">Delete</button>
|
||||
<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>
|
||||
@@ -884,17 +858,17 @@ onMounted(load);
|
||||
<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>
|
||||
<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>
|
||||
Add reminder
|
||||
{{ 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">
|
||||
Nothing to be reminded about yet.
|
||||
{{ t("car.reminders.empty") }}
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-2">
|
||||
@@ -906,14 +880,14 @@ onMounted(load);
|
||||
<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>
|
||||
<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">at {{ formatKm(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>
|
||||
@@ -923,11 +897,11 @@ onMounted(load);
|
||||
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' }}
|
||||
{{ 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)">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>
|
||||
<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>
|
||||
@@ -937,42 +911,42 @@ onMounted(load);
|
||||
<!-- 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">Parts catalog</h2>
|
||||
<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>
|
||||
Add part
|
||||
{{ 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">
|
||||
No parts yet.
|
||||
{{ 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>Part</th>
|
||||
<th>Part number</th>
|
||||
<th>Notes</th>
|
||||
<th>File</th>
|
||||
<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 || '—' }}</td>
|
||||
<td class="px-4 py-3 text-body">{{ p.notes || '—' }}</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)">
|
||||
Download
|
||||
{{ t("common.download") }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
<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)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deletePart(p.id)">Delete</button>
|
||||
<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>
|
||||
@@ -1040,23 +1014,26 @@ onMounted(load);
|
||||
<!-- 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">Delete this car?</h2>
|
||||
<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">
|
||||
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.
|
||||
{{ 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">
|
||||
Type <span class="data text-strong">{{ car.name }}</span> to confirm
|
||||
{{ 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">Cancel</button>
|
||||
<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 ? 'Deleting…' : 'Delete permanently' }}
|
||||
{{ deletingCar ? t("car.delete.deleting") : t("car.delete.confirm") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { formatDate, formatKm, serviceStatus } from "../lib/format.js";
|
||||
import { t, tSplit } from "../i18n";
|
||||
import CarFormModal from "../components/CarFormModal.vue";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -61,21 +62,22 @@ onMounted(load);
|
||||
<div>
|
||||
<div class="mb-6 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="eyebrow">Garage</p>
|
||||
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Your cars</h1>
|
||||
<p class="mt-1 text-sm text-muted">Maintenance overview and service history.</p>
|
||||
<p class="eyebrow">{{ t("dashboard.eyebrow") }}</p>
|
||||
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("dashboard.title") }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ t("dashboard.subtitle") }}</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-primary" @click="showAdd = true">
|
||||
<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 car
|
||||
{{ t("dashboard.addCar") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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">Loading…</p>
|
||||
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
|
||||
|
||||
<div v-else-if="cars.length === 0" class="rounded-card border border-dashed border-default p-12 text-center text-muted">
|
||||
No cars yet. Click <strong class="text-strong">Add car</strong> to get started.
|
||||
{{ tSplit("dashboard.empty", "action").before
|
||||
}}<strong class="text-strong">{{ t("dashboard.addCar") }}</strong>{{ tSplit("dashboard.empty", "action").after }}
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -99,13 +101,13 @@ onMounted(load);
|
||||
v-if="car.access && car.access !== 'owner'"
|
||||
class="dh-badge dh-badge-neutral mt-2"
|
||||
>
|
||||
Shared{{ car.access === 'read' ? ' · read-only' : '' }}
|
||||
{{ car.access === 'read' ? t("dashboard.sharedReadOnly") : t("dashboard.shared") }}
|
||||
</span>
|
||||
|
||||
<!-- Service-life bar: fraction of the km interval used up. -->
|
||||
<div v-if="serviceLife(car)" class="mt-4">
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<span class="eyebrow">Service life</span>
|
||||
<span class="eyebrow">{{ t("dashboard.serviceLife") }}</span>
|
||||
<span class="data text-xs font-medium text-strong">{{ serviceLife(car).pct }}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-pill bg-sunken">
|
||||
@@ -115,25 +117,25 @@ onMounted(load);
|
||||
|
||||
<dl class="mt-4 space-y-2 text-sm">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt class="eyebrow">Last service</dt>
|
||||
<dt class="eyebrow">{{ t("dashboard.lastService") }}</dt>
|
||||
<dd class="data font-medium text-strong">{{ formatDate(car.latest?.date) }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt class="eyebrow">Odometer</dt>
|
||||
<dt class="eyebrow">{{ t("dashboard.odometer") }}</dt>
|
||||
<dd class="data font-medium text-strong">{{ formatKm(car.currentKm) }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt class="eyebrow">Next due</dt>
|
||||
<dt class="eyebrow">{{ t("dashboard.nextDue") }}</dt>
|
||||
<dd class="data font-medium text-strong">{{ formatDate(car.latest?.nextServiceDate) }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt class="eyebrow">Next due km</dt>
|
||||
<dt class="eyebrow">{{ t("dashboard.nextDueKm") }}</dt>
|
||||
<dd class="data font-medium text-strong">{{ formatKm(car.latest?.nextServiceKm) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="mt-4 border-t border-subtle pt-3 text-xs text-muted">
|
||||
{{ car.count }} service record{{ car.count === 1 ? '' : 's' }}
|
||||
{{ t("dashboard.serviceRecords", { n: car.count }) }}
|
||||
</p>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { login } from "../auth";
|
||||
import { getServerUrl, setServerUrl, DEFAULT_API_BASE } from "../api";
|
||||
import { t, tSplit } from "../i18n";
|
||||
import Logo from "../components/Logo.vue";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -41,7 +42,7 @@ async function submit() {
|
||||
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
|
||||
router.replace(redirect);
|
||||
} catch (e) {
|
||||
error.value = e.message || "Login failed";
|
||||
error.value = e.message || t("login.failed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -53,23 +54,23 @@ async function submit() {
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-7 text-center">
|
||||
<Logo class="mx-auto mb-4 h-10 w-auto" />
|
||||
<h1 class="text-2xl font-bold tracking-[-0.02em] text-strong">Sign in</h1>
|
||||
<p class="mt-1 text-sm text-muted">Your car, on track.</p>
|
||||
<h1 class="text-2xl font-bold tracking-[-0.02em] text-strong">{{ t("login.title") }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ t("login.tagline") }}</p>
|
||||
</div>
|
||||
|
||||
<form class="dh-card space-y-4 p-6" @submit.prevent="submit">
|
||||
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
|
||||
<div>
|
||||
<label class="dh-label">Email</label>
|
||||
<label class="dh-label">{{ t("login.email") }}</label>
|
||||
<input v-model="email" type="email" required autocomplete="username" class="dh-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Password</label>
|
||||
<label class="dh-label">{{ t("login.password") }}</label>
|
||||
<div class="relative">
|
||||
<input v-model="password" :type="showPassword ? 'text' : 'password'" required autocomplete="current-password"
|
||||
class="dh-input pr-10" />
|
||||
<button type="button" @click="showPassword = !showPassword"
|
||||
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
||||
:aria-label="showPassword ? t('login.hidePassword') : t('login.showPassword')"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-muted transition-colors hover:text-strong">
|
||||
<svg v-if="showPassword" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
@@ -82,29 +83,30 @@ async function submit() {
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" :disabled="loading" class="dh-btn dh-btn-primary w-full">
|
||||
{{ loading ? "Signing in…" : "Sign in" }}
|
||||
{{ loading ? t("login.submitting") : t("login.submit") }}
|
||||
</button>
|
||||
|
||||
<!-- Server settings: optional override of the API server address -->
|
||||
<div class="border-t border-subtle pt-3">
|
||||
<button type="button" @click="showServer = !showServer"
|
||||
class="flex w-full items-center justify-between text-xs font-semibold text-muted transition-colors hover:text-strong">
|
||||
<span>Server settings</span>
|
||||
<span>{{ t("login.serverSettings") }}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
||||
class="h-4 w-4 transition-transform" :class="showServer ? 'rotate-180' : ''">
|
||||
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showServer" class="mt-3 space-y-2">
|
||||
<label class="eyebrow block">API server URL</label>
|
||||
<label class="eyebrow block">{{ t("login.apiServerUrl") }}</label>
|
||||
<input v-model="serverUrl" type="text" :placeholder="DEFAULT_API_BASE" autocomplete="off" class="dh-input data" />
|
||||
<p class="text-xs text-muted">
|
||||
Leave blank to use the default (<span class="data">{{ DEFAULT_API_BASE }}</span>).
|
||||
{{ tSplit("login.leaveBlank", "url").before
|
||||
}}<span class="data">{{ DEFAULT_API_BASE }}</span>{{ tSplit("login.leaveBlank", "url").after }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" @click="saveServer" class="dh-btn dh-btn-ghost !px-3 !py-1.5 !text-xs">Save</button>
|
||||
<button type="button" @click="resetServer" class="dh-btn !px-3 !py-1.5 !text-xs text-muted hover:bg-sunken">Reset to default</button>
|
||||
<span v-if="serverSaved" class="text-xs font-medium text-success">Saved ✓</span>
|
||||
<button type="button" @click="saveServer" class="dh-btn dh-btn-ghost !px-3 !py-1.5 !text-xs">{{ t("common.save") }}</button>
|
||||
<button type="button" @click="resetServer" class="dh-btn !px-3 !py-1.5 !text-xs text-muted hover:bg-sunken">{{ t("login.resetToDefault") }}</button>
|
||||
<span v-if="serverSaved" class="text-xs font-medium text-success">{{ t("common.saved") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api } from "../api";
|
||||
import { state, logout, refreshProfile } from "../auth";
|
||||
import { prefs, applyProfilePrefs } from "../prefs";
|
||||
import { formatDate, formatMoney } from "../lib/format.js";
|
||||
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -89,11 +90,11 @@ const passwordMismatch = computed(
|
||||
async function savePassword() {
|
||||
passwordError.value = "";
|
||||
if (newPassword.value.length < 8) {
|
||||
passwordError.value = "New password must be at least 8 characters.";
|
||||
passwordError.value = t("settings.account.tooShort");
|
||||
return;
|
||||
}
|
||||
if (passwordMismatch.value) {
|
||||
passwordError.value = "New password and confirmation don't match.";
|
||||
passwordError.value = t("settings.account.mismatch");
|
||||
return;
|
||||
}
|
||||
passwordSaving.value = true;
|
||||
@@ -190,6 +191,12 @@ const CURRENCIES = computed(() => named(CURRENCY_CODES, "currency", true));
|
||||
const language = computed(() => (prefs.locale || "en-US").split("-")[0]);
|
||||
const region = computed(() => (prefs.locale || "en-US").split("-")[1] || "US");
|
||||
|
||||
// The picker offers every European language because the choice also drives date
|
||||
// and number formatting, which Intl handles for all of them. Only a few have a
|
||||
// translation file, though, so say so rather than letting someone pick Georgian
|
||||
// and wonder why the buttons are still English.
|
||||
const languageTranslated = computed(() => TRANSLATED_LANGUAGES.includes(language.value));
|
||||
|
||||
function saveLocale({ lang = language.value, reg = region.value }) {
|
||||
return saveAppearance({ locale: `${lang}-${reg}` });
|
||||
}
|
||||
@@ -318,18 +325,14 @@ async function onImportFileChosen(e) {
|
||||
try {
|
||||
payload = JSON.parse(await file.text());
|
||||
} catch {
|
||||
importError.value = "That file isn't valid JSON.";
|
||||
importError.value = t("settings.advanced.notJson");
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(payload?.cars) || payload.cars.length === 0) {
|
||||
importError.value = "That file doesn't look like a DriverVault export (missing a \"cars\" list).";
|
||||
importError.value = t("settings.advanced.notExport");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
`Import ${payload.cars.length} car(s) from this file? This adds new records — it does not merge with or overwrite existing cars.`
|
||||
)
|
||||
) {
|
||||
if (!confirm(t("settings.advanced.confirmImport", { count: payload.cars.length }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -390,7 +393,7 @@ async function cancelDeletion() {
|
||||
}
|
||||
|
||||
async function finalizeDeletion() {
|
||||
if (!confirm("This permanently deletes your account. This cannot be undone. Continue?")) return;
|
||||
if (!confirm(t("settings.danger.confirmFinalize"))) return;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await api.finalizeAccountDeletion();
|
||||
@@ -414,38 +417,38 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mb-6">
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Settings</h1>
|
||||
<p class="mt-1 text-sm text-muted">Manage your account, appearance, and data.</p>
|
||||
<p class="eyebrow">{{ t("settings.eyebrow") }}</p>
|
||||
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("settings.title") }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ t("settings.subtitle") }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="loadError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ loadError }}</p>
|
||||
<p v-if="loading" class="text-muted">Loading…</p>
|
||||
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
|
||||
|
||||
<div v-else-if="profile" class="space-y-6">
|
||||
<!-- Account -->
|
||||
<section class="dh-card p-6">
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Account</h2>
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.account.title") }}</h2>
|
||||
|
||||
<div class="mb-5">
|
||||
<label class="dh-label">Name</label>
|
||||
<label class="dh-label">{{ t("settings.account.name") }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="nameDraft" class="dh-input max-w-sm" />
|
||||
<button class="dh-btn dh-btn-primary" :disabled="nameSaving || !nameDraft.trim()" @click="saveName">
|
||||
{{ nameSaving ? "Saving…" : nameSaved ? "Saved ✓" : "Save" }}
|
||||
{{ nameSaving ? t("common.saving") : nameSaved ? t("common.saved") : t("common.save") }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="nameError" class="mt-1 text-sm text-danger">{{ nameError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<label class="dh-label">Email</label>
|
||||
<label class="dh-label">{{ t("settings.account.email") }}</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="data rounded-control border border-subtle bg-sunken px-3 py-2 text-sm text-body">
|
||||
{{ profile.email }}
|
||||
</span>
|
||||
<span class="dh-badge" :class="profile.verified ? 'dh-badge-success' : 'dh-badge-warning'">
|
||||
{{ profile.verified ? "Verified" : "Not verified" }}
|
||||
{{ profile.verified ? t("settings.account.verified") : t("settings.account.notVerified") }}
|
||||
</span>
|
||||
<button
|
||||
v-if="!profile.verified && !verifySent"
|
||||
@@ -453,101 +456,101 @@ onBeforeUnmount(() => {
|
||||
:disabled="verifySending"
|
||||
@click="sendVerification"
|
||||
>
|
||||
{{ verifySending ? "Sending…" : "Resend verification email" }}
|
||||
{{ verifySending ? t("settings.account.sending") : t("settings.account.resendVerification") }}
|
||||
</button>
|
||||
<span v-if="verifySent" class="text-sm text-muted">Verification email requested.</span>
|
||||
<span v-if="verifySent" class="text-sm text-muted">{{ t("settings.account.verificationRequested") }}</span>
|
||||
</div>
|
||||
<p v-if="verifyError" class="mt-1 text-sm text-danger">{{ verifyError }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold text-strong">Change password</h3>
|
||||
<h3 class="mb-2 text-sm font-semibold text-strong">{{ t("settings.account.changePassword") }}</h3>
|
||||
<div class="grid max-w-sm gap-2">
|
||||
<input v-model="oldPassword" type="password" placeholder="Current password" autocomplete="current-password" class="dh-input" />
|
||||
<input v-model="newPassword" type="password" placeholder="New password" autocomplete="new-password" class="dh-input" />
|
||||
<input v-model="confirmPassword" type="password" placeholder="Confirm new password" autocomplete="new-password" class="dh-input" />
|
||||
<input v-model="oldPassword" type="password" :placeholder="t('settings.account.currentPassword')" autocomplete="current-password" class="dh-input" />
|
||||
<input v-model="newPassword" type="password" :placeholder="t('settings.account.newPassword')" autocomplete="new-password" class="dh-input" />
|
||||
<input v-model="confirmPassword" type="password" :placeholder="t('settings.account.confirmNewPassword')" autocomplete="new-password" class="dh-input" />
|
||||
</div>
|
||||
<p v-if="passwordMismatch" class="mt-1 text-sm text-warning">Passwords don't match yet.</p>
|
||||
<p v-if="passwordMismatch" class="mt-1 text-sm text-warning">{{ t("settings.account.mismatchYet") }}</p>
|
||||
<p v-if="passwordError" class="mt-1 text-sm text-danger">{{ passwordError }}</p>
|
||||
<button class="dh-btn dh-btn-ghost mt-2" :disabled="passwordSaving || !oldPassword || !newPassword" @click="savePassword">
|
||||
{{ passwordSaving ? "Updating…" : passwordSaved ? "Password updated ✓" : "Update password" }}
|
||||
{{ passwordSaving ? t("settings.account.updating") : passwordSaved ? t("settings.account.passwordUpdated") : t("settings.account.updatePassword") }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Appearance -->
|
||||
<section class="dh-card p-6">
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Appearance</h2>
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.appearance.title") }}</h2>
|
||||
|
||||
<div class="mb-5">
|
||||
<label class="dh-label">Theme</label>
|
||||
<label class="dh-label">{{ t("settings.appearance.theme") }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="t in ['light', 'dark', 'system']"
|
||||
:key="t"
|
||||
class="rounded-control border px-3 py-1.5 text-sm font-medium capitalize transition-colors"
|
||||
:class="prefs.theme === t
|
||||
v-for="opt in ['light', 'dark', 'system']"
|
||||
:key="opt"
|
||||
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
:class="prefs.theme === opt
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
|
||||
@click="saveAppearance({ theme: t })"
|
||||
@click="saveAppearance({ theme: opt })"
|
||||
>
|
||||
{{ t }}
|
||||
{{ t(`settings.appearance.theme${opt.charAt(0).toUpperCase() + opt.slice(1)}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="dh-label">Language</label>
|
||||
<label class="dh-label">{{ t("settings.appearance.language") }}</label>
|
||||
<select :value="language" class="dh-input" @change="saveLocale({ lang: $event.target.value })">
|
||||
<option v-for="l in LANGUAGES" :key="l.code" :value="l.code">{{ l.label }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">Names of months and days.</p>
|
||||
<p class="mt-1 text-xs" :class="languageTranslated ? 'text-muted' : 'text-warning'">
|
||||
{{ languageTranslated ? t("settings.appearance.languageHint") : t("settings.appearance.languageFallbackHint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Region</label>
|
||||
<label class="dh-label">{{ t("settings.appearance.region") }}</label>
|
||||
<select :value="region" class="dh-input" @change="saveLocale({ reg: $event.target.value })">
|
||||
<option v-for="r in REGIONS" :key="r.code" :value="r.code">{{ r.label }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">Number and currency layout.</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.regionHint") }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Date format</label>
|
||||
<label class="dh-label">{{ t("settings.appearance.dateFormat") }}</label>
|
||||
<select :value="prefs.dateFormat" class="dh-input" @change="saveAppearance({ dateFormat: $event.target.value })">
|
||||
<option value="YMD">YYYY-MM-DD</option>
|
||||
<option value="DMY_NUM">DD-MM-YYYY</option>
|
||||
<option value="DMY">DD Mon YYYY</option>
|
||||
<option value="MDY">Mon DD, YYYY</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">Example: <span class="data">{{ dateFormatExample }}</span></p>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.dateExample", { example: dateFormatExample }) }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Currency</label>
|
||||
<label class="dh-label">{{ t("settings.appearance.currency") }}</label>
|
||||
<select :value="prefs.currency" class="dh-input" @change="saveAppearance({ currency: $event.target.value })">
|
||||
<option v-for="c in CURRENCIES" :key="c.code" :value="c.code">{{ c.label }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
Example: <span class="data">{{ currencyExample }}</span> — display only, no amounts are converted.
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.currencyExample", { example: currencyExample }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Font size</label>
|
||||
<label class="dh-label">{{ t("settings.appearance.fontSize") }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="f in ['small', 'medium', 'large']"
|
||||
:key="f"
|
||||
class="rounded-control border px-3 py-1.5 text-sm font-medium capitalize transition-colors"
|
||||
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
:class="prefs.fontSize === f
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
|
||||
@click="saveAppearance({ fontSize: f })"
|
||||
>
|
||||
{{ f }}
|
||||
{{ t(`settings.appearance.font${f.charAt(0).toUpperCase() + f.slice(1)}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -557,19 +560,19 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Profile -->
|
||||
<section class="dh-card p-6">
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Profile</h2>
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.profile.title") }}</h2>
|
||||
|
||||
<div class="mb-5 flex items-center gap-4">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" alt="Avatar" class="h-16 w-16 rounded-full object-cover ring-1 ring-subtle" />
|
||||
<img v-if="avatarUrl" :src="avatarUrl" :alt="t('settings.profile.avatarAlt')" class="h-16 w-16 rounded-full object-cover ring-1 ring-subtle" />
|
||||
<div v-else class="grid h-16 w-16 place-items-center rounded-full bg-brand-100 text-xl font-bold text-brandtext">
|
||||
{{ (profile.name || profile.email || "?").charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="pickAvatar">
|
||||
{{ avatarUploading ? "Uploading…" : "Upload photo" }}
|
||||
{{ avatarUploading ? t("settings.profile.uploading") : t("settings.profile.uploadPhoto") }}
|
||||
</button>
|
||||
<button v-if="profile.hasAvatar" class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="removeAvatar">
|
||||
Remove
|
||||
{{ t("common.remove") }}
|
||||
</button>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" class="hidden" @change="onAvatarChosen" />
|
||||
@@ -577,16 +580,16 @@ onBeforeUnmount(() => {
|
||||
<p v-if="avatarError" class="mb-4 text-sm text-danger">{{ avatarError }}</p>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Bio</label>
|
||||
<label class="dh-label">{{ t("settings.profile.bio") }}</label>
|
||||
<textarea
|
||||
v-model="bioDraft"
|
||||
rows="3"
|
||||
placeholder="A short note visible to other people in your household."
|
||||
:placeholder="t('settings.profile.bioPlaceholder')"
|
||||
class="dh-input"
|
||||
/>
|
||||
<p v-if="bioError" class="mt-1 text-sm text-danger">{{ bioError }}</p>
|
||||
<button class="dh-btn dh-btn-ghost mt-2" :disabled="bioSaving" @click="saveBio">
|
||||
{{ bioSaving ? "Saving…" : bioSaved ? "Saved ✓" : "Save bio" }}
|
||||
{{ bioSaving ? t("common.saving") : bioSaved ? t("common.saved") : t("settings.profile.saveBio") }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -594,73 +597,64 @@ onBeforeUnmount(() => {
|
||||
<!-- Privacy & Security -->
|
||||
<section class="dh-card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Privacy & security</h2>
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.privacy.title") }}</h2>
|
||||
<button class="text-sm font-medium text-danger hover:underline" @click="onLogout">
|
||||
Sign out
|
||||
{{ t("settings.privacy.signOut") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted">
|
||||
Two-factor authentication isn't available yet. Sessions are held as
|
||||
server-issued tokens that expire on their own, so signing out here ends
|
||||
this device's session only — there's no per-device list to revoke from.
|
||||
To lock out every device, change your password above.
|
||||
</p>
|
||||
<p class="text-sm text-muted">{{ t("settings.privacy.body") }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Advanced / Danger Zone -->
|
||||
<section class="dh-card p-6">
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Advanced</h2>
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.advanced.title") }}</h2>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-strong">Export your data</p>
|
||||
<p class="text-xs text-muted">Download your profile and all cars, service records, and parts as JSON.</p>
|
||||
<p class="text-sm font-medium text-strong">{{ t("settings.advanced.exportTitle") }}</p>
|
||||
<p class="text-xs text-muted">{{ t("settings.advanced.exportBody") }}</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="exporting" @click="exportData">
|
||||
{{ exporting ? "Preparing…" : "Export data" }}
|
||||
{{ exporting ? t("settings.advanced.preparing") : t("settings.advanced.exportAction") }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="exportError" class="mt-2 text-sm text-danger">{{ exportError }}</p>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-3 border-t border-subtle pt-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-strong">Import your data</p>
|
||||
<p class="text-xs text-muted">
|
||||
Add cars from a previously exported JSON file. This creates new records — it doesn't merge with or overwrite anything existing.
|
||||
</p>
|
||||
<p class="text-sm font-medium text-strong">{{ t("settings.advanced.importTitle") }}</p>
|
||||
<p class="text-xs text-muted">{{ t("settings.advanced.importBody") }}</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="importing" @click="pickImportFile">
|
||||
{{ importing ? "Importing…" : "Import data" }}
|
||||
{{ importing ? t("settings.advanced.importing") : t("settings.advanced.importAction") }}
|
||||
</button>
|
||||
<input ref="importFileInput" type="file" accept="application/json,.json" class="hidden" @change="onImportFileChosen" />
|
||||
</div>
|
||||
<p v-if="importResult" class="mt-2 text-sm font-medium text-success">
|
||||
Imported {{ importResult.carsImported }} car(s), {{ importResult.servicesImported }} service record(s), {{ importResult.partsImported }} part(s).
|
||||
{{ t("settings.advanced.imported", { cars: importResult.carsImported, services: importResult.servicesImported, parts: importResult.partsImported }) }}
|
||||
</p>
|
||||
<p v-if="importError" class="mt-2 text-sm text-danger">{{ importError }}</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-card border border-danger/30 bg-danger-soft p-6">
|
||||
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">Danger zone</h2>
|
||||
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">{{ t("settings.danger.title") }}</h2>
|
||||
|
||||
<template v-if="!deletionPending">
|
||||
<p class="mb-3 text-sm text-danger/90">
|
||||
Deleting your account removes your login and profile. It does not delete your household's shared cars or
|
||||
service history. There's a 3-day cooldown before the deletion is final, and you can cancel any time before then.
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-danger/90">{{ t("settings.danger.body") }}</p>
|
||||
<button class="dh-btn !border !border-danger/40 !bg-transparent !text-danger hover:!bg-danger/10" @click="showDeleteConfirm = true">
|
||||
Delete my account
|
||||
{{ t("settings.danger.deleteAccount") }}
|
||||
</button>
|
||||
|
||||
<div v-if="showDeleteConfirm" class="mt-4 rounded-control border border-danger/30 bg-card p-4">
|
||||
<label class="dh-label">
|
||||
Type <span class="data text-strong">{{ profile.email }}</span> to confirm
|
||||
{{ tSplit("settings.danger.typeToConfirm", "email").before
|
||||
}}<span class="data text-strong">{{ profile.email }}</span>{{ tSplit("settings.danger.typeToConfirm", "email").after }}
|
||||
</label>
|
||||
<input v-model="deleteConfirmEmail" :placeholder="profile.email" class="dh-input mb-3 max-w-sm" />
|
||||
<div class="flex gap-2">
|
||||
<button class="dh-btn dh-btn-ghost" @click="showDeleteConfirm = false; deleteConfirmEmail = ''">Cancel</button>
|
||||
<button class="dh-btn dh-btn-ghost" @click="showDeleteConfirm = false; deleteConfirmEmail = ''">{{ t("common.cancel") }}</button>
|
||||
<button :disabled="!canRequestDelete || deleteRequesting" class="dh-btn dh-btn-danger" @click="requestDeletion">
|
||||
{{ deleteRequesting ? "Requesting…" : "Request deletion" }}
|
||||
{{ deleteRequesting ? t("settings.danger.requesting") : t("settings.danger.requestDeletion") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -668,14 +662,13 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template v-else>
|
||||
<p class="mb-3 text-sm text-danger/90">
|
||||
Account deletion requested on {{ formatDate(profile.deletionRequestedAt) }}.
|
||||
<template v-if="!cooldownElapsed">You can still cancel — it becomes permanent after the 3-day cooldown.</template>
|
||||
<template v-else>The cooldown has passed. You can now finalize the deletion.</template>
|
||||
{{ t("settings.danger.requestedOn", { date: formatDate(profile.deletionRequestedAt) }) }}
|
||||
{{ cooldownElapsed ? t("settings.danger.cooldownPassed") : t("settings.danger.canStillCancel") }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button class="dh-btn dh-btn-ghost !bg-card" @click="cancelDeletion">Cancel deletion request</button>
|
||||
<button class="dh-btn dh-btn-ghost !bg-card" @click="cancelDeletion">{{ t("settings.danger.cancelRequest") }}</button>
|
||||
<button v-if="cooldownElapsed" class="dh-btn dh-btn-danger" @click="finalizeDeletion">
|
||||
Permanently delete my account
|
||||
{{ t("settings.danger.finalize") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user