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:
tajniak81
2026-07-17 20:07:48 +02:00
co-authored by Claude Opus 4.8
parent ee28b522c7
commit b6bb6b1df0
54 changed files with 4191 additions and 979 deletions
+7 -6
View File
@@ -4,6 +4,7 @@ import { RouterView, RouterLink, useRouter, useRoute } from "vue-router";
import { state, isAuthenticated, isAdmin, logout, refreshProfile } from "./auth";
import { prefs, applyProfilePrefs } from "./prefs";
import { api } from "./api";
import { t } from "./i18n";
import Logo from "./components/Logo.vue";
const router = useRouter();
@@ -34,9 +35,9 @@ const userInitial = computed(() =>
// Sidebar nav. Admin item is filtered out for non-admins.
const nav = computed(() =>
[
{ to: "/", label: "Garage", icon: "grid", exact: true },
{ to: "/settings", label: "Settings", icon: "gear" },
isAdmin.value ? { to: "/admin", label: "Users", icon: "users" } : null,
{ to: "/", label: t("nav.garage"), icon: "grid", exact: true },
{ to: "/settings", label: t("nav.settings"), icon: "gear" },
isAdmin.value ? { to: "/admin", label: t("nav.users"), icon: "users" } : null,
].filter(Boolean)
);
@@ -99,16 +100,16 @@ onBeforeUnmount(() => themeObserver?.disconnect());
>
<svg v-if="isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0"><circle cx="12" cy="12" r="4"/><path stroke-linecap="round" d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0"><path stroke-linecap="round" stroke-linejoin="round" d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/></svg>
<span class="hidden md:inline">{{ isDark ? "Light mode" : "Dark mode" }}</span>
<span class="hidden md:inline">{{ isDark ? t("nav.lightMode") : t("nav.darkMode") }}</span>
</button>
<div class="flex items-center gap-3 px-1 md:px-2">
<div class="grid h-9 w-9 flex-none place-items-center rounded-full bg-brand-600 font-mono text-sm text-white">{{ userInitial }}</div>
<div class="hidden min-w-0 flex-1 md:block">
<p class="truncate text-sm text-white">{{ state.user?.name || state.user?.email }}</p>
<p class="truncate font-mono text-[11px] text-white/50">Signed in</p>
<p class="truncate font-mono text-[11px] text-white/50">{{ t("nav.signedIn") }}</p>
</div>
<button class="hidden text-white/50 transition-colors hover:text-white md:block" title="Log out" @click="onLogout">
<button class="hidden text-white/50 transition-colors hover:text-white md:block" :title="t('nav.logOut')" @click="onLogout">
<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="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3"/></svg>
</button>
</div>
+2 -1
View File
@@ -1,6 +1,7 @@
// Single client for the Car Control API Server. The web app never talks to
// PocketBase directly — only to these endpoints (proxied to the API Server in
// dev via vite.config.js).
import { t } from "./i18n";
// Default API base: the Vite env override, else the same-origin "/api" (proxied
// to the API Server in dev). A user can override this at runtime via the login
@@ -39,7 +40,7 @@ async function handleResponse(res, path) {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
if (location.pathname !== "/login") location.href = "/login";
throw new Error("Session expired — please log in again.");
throw new Error(t("errors.sessionExpired"));
}
if (res.status === 204) return null;
+14 -8
View File
@@ -4,14 +4,19 @@
// It only collects intent — a picked file, or a request to detach the existing
// one. Actually moving the bytes is the parent's job (applyAttachment), because
// the endpoint addresses a record that must already exist.
import { t, tSplit } from "../i18n";
defineProps({
// The saved record, when editing; null while creating. Read for the name of
// whatever is already attached.
record: { type: Object, default: null },
file: { type: Object, default: null },
remove: { type: Boolean, default: false },
legend: { type: String, default: "Attachment" },
hint: { type: String, default: "PDF or image, up to 10MB." },
// Null rather than a literal default: the fallback has to be resolved at
// render time so it follows a language change, which a prop default evaluated
// once at definition time would not.
legend: { type: String, default: null },
hint: { type: String, default: null },
});
const emit = defineEmits(["update:file", "update:remove"]);
@@ -25,7 +30,7 @@ function onFilePick(e) {
<template>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">{{ legend }}</legend>
<legend class="eyebrow px-1">{{ legend ?? t("attachment.legend") }}</legend>
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp,.heic"
@@ -33,17 +38,18 @@ function onFilePick(e) {
@change="onFilePick"
/>
<p v-if="record?.hasFile && !file && !remove" class="mt-2 flex items-center gap-2 text-xs text-muted">
<span>Attached: <span class="data text-strong">{{ record.fileName }}</span></span>
<span>{{ tSplit("attachment.attached", "name").before
}}<span class="data text-strong">{{ record.fileName }}</span>{{ tSplit("attachment.attached", "name").after }}</span>
<button type="button" class="font-medium text-danger hover:underline" @click="emit('update:remove', true)">
Remove
{{ t("common.remove") }}
</button>
</p>
<p v-else-if="remove" class="mt-2 flex items-center gap-2 text-xs text-muted">
<span>Attachment will be removed on save.</span>
<span>{{ t("attachment.willBeRemoved") }}</span>
<button type="button" class="font-medium text-brandtext hover:underline" @click="emit('update:remove', false)">
Undo
{{ t("common.undo") }}
</button>
</p>
<p class="mt-1.5 text-xs text-muted">{{ hint }}</p>
<p class="mt-1.5 text-xs text-muted">{{ hint ?? t("attachment.hint") }}</p>
</fieldset>
</template>
+34 -36
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref } from "vue";
import { api } from "../api";
import { t } from "../i18n";
import Modal from "./Modal.vue";
const props = defineProps({ car: { type: Object, default: null } });
@@ -69,116 +70,113 @@ async function submit() {
</script>
<template>
<Modal :title="isEdit ? 'Edit car' : 'Add a car'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.car.editTitle') : t('forms.car.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div>
<label class="dh-label">Name *</label>
<label class="dh-label">{{ t("forms.car.name") }}</label>
<input v-model="form.name" required placeholder="Toyota Yaris" class="dh-input" />
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="dh-label">Make</label>
<label class="dh-label">{{ t("forms.car.make") }}</label>
<input v-model="form.make" placeholder="Toyota" class="dh-input" />
</div>
<div>
<label class="dh-label">Model</label>
<label class="dh-label">{{ t("forms.car.model") }}</label>
<input v-model="form.model" placeholder="Yaris" class="dh-input" />
</div>
<div>
<label class="dh-label">Year</label>
<label class="dh-label">{{ t("forms.car.year") }}</label>
<input v-model="form.year" type="number" placeholder="2015" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="dh-label">Registration</label>
<label class="dh-label">{{ t("forms.car.registration") }}</label>
<input v-model="form.registration" placeholder="ABC 1234" class="dh-input" />
</div>
<div>
<label class="dh-label">Registration country</label>
<input v-model="form.registrationCountry" placeholder="Poland" class="dh-input" />
<label class="dh-label">{{ t("forms.car.registrationCountry") }}</label>
<input v-model="form.registrationCountry" :placeholder="t('forms.car.registrationCountryPlaceholder')" class="dh-input" />
</div>
<div>
<label class="dh-label">VIN</label>
<input v-model="form.vin" placeholder="Vehicle Identification Number" maxlength="17" class="dh-input data uppercase" />
<label class="dh-label">{{ t("forms.car.vin") }}</label>
<input v-model="form.vin" :placeholder="t('forms.car.vinPlaceholder')" maxlength="17" class="dh-input data uppercase" />
</div>
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="dh-label">Fuel type</label>
<label class="dh-label">{{ t("forms.car.fuelType") }}</label>
<select v-model="form.fuelType" class="dh-input">
<option value=""></option>
<option value="petrol">Petrol (gasoline)</option>
<option value="petrol_lpg">Petrol (gasoline) + LPG</option>
<option value="diesel">Diesel</option>
<option value="diesel_lpg">Diesel + LPG</option>
<option value="hybrid">Hybrid</option>
<option value="electric">Electric</option>
<option value="hydrogen">Hydrogen</option>
<option value="">{{ t("common.empty") }}</option>
<option value="petrol">{{ t("enums.fuelType.petrol") }}</option>
<option value="petrol_lpg">{{ t("enums.fuelType.petrol_lpg") }}</option>
<option value="diesel">{{ t("enums.fuelType.diesel") }}</option>
<option value="diesel_lpg">{{ t("enums.fuelType.diesel_lpg") }}</option>
<option value="hybrid">{{ t("enums.fuelType.hybrid") }}</option>
<option value="electric">{{ t("enums.fuelType.electric") }}</option>
<option value="hydrogen">{{ t("enums.fuelType.hydrogen") }}</option>
</select>
</div>
<div>
<label class="dh-label">Build date</label>
<label class="dh-label">{{ t("forms.car.buildDate") }}</label>
<input v-model="form.buildDate" type="date" class="dh-input data" />
</div>
<div>
<label class="dh-label">First registration</label>
<label class="dh-label">{{ t("forms.car.firstRegistration") }}</label>
<input v-model="form.firstRegistrationDate" type="date" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Engine oil spec</label>
<label class="dh-label">{{ t("forms.car.oilSpec") }}</label>
<input v-model="form.oilSpec" placeholder="0W20" class="dh-input" />
</div>
<div>
<label class="dh-label">Current odometer (km)</label>
<label class="dh-label">{{ t("forms.car.currentKm") }}</label>
<input v-model="form.currentKm" type="number" placeholder="270185" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Transmission oil spec</label>
<label class="dh-label">{{ t("forms.car.transmissionOilSpec") }}</label>
<input v-model="form.transmissionOilSpec" placeholder="Toyota WS" class="dh-input" />
</div>
<div>
<label class="dh-label">Differential oil spec</label>
<label class="dh-label">{{ t("forms.car.differentialOilSpec") }}</label>
<input v-model="form.differentialOilSpec" placeholder="SAE 75W-90 GL-5" class="dh-input" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Brake fluid spec</label>
<label class="dh-label">{{ t("forms.car.brakeFluidSpec") }}</label>
<input v-model="form.brakeFluidSpec" placeholder="DOT 4" class="dh-input" />
</div>
<div>
<label class="dh-label">Coolant spec</label>
<label class="dh-label">{{ t("forms.car.coolantSpec") }}</label>
<input v-model="form.coolantSpec" placeholder="Toyota Super Long Life Coolant" class="dh-input" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Service interval (days)</label>
<label class="dh-label">{{ t("forms.car.serviceIntervalDays") }}</label>
<input v-model="form.serviceIntervalDays" type="number" class="dh-input data" />
</div>
<div>
<label class="dh-label">Service interval (km)</label>
<label class="dh-label">{{ t("forms.car.serviceIntervalKm") }}</label>
<input v-model="form.serviceIntervalKm" type="number" class="dh-input data" />
</div>
</div>
<div>
<label class="dh-label">Technical check interval (days)</label>
<label class="dh-label">{{ t("forms.car.technicalCheckIntervalDays") }}</label>
<input v-model="form.technicalCheckIntervalDays" type="number" class="dh-input data" />
<p class="mt-1 text-xs text-muted">
Prefills each check's next-due date. Any check can override it with the date printed on
its certificate.
</p>
<p class="mt-1 text-xs text-muted">{{ t("forms.car.technicalCheckHint") }}</p>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Add car" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.car.submit") }}
</button>
</div>
</form>
@@ -2,6 +2,7 @@
import { ref } from "vue";
import { api } from "../api";
import { applyAttachment } from "../lib/attachment.js";
import { t } from "../i18n";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
@@ -15,15 +16,7 @@ const isEdit = !!props.doc;
const saving = ref(false);
const error = ref("");
const TYPES = [
{ value: "insurance", label: "Insurance" },
{ value: "pollution", label: "Pollution certificate" },
{ value: "registration", label: "Registration" },
{ value: "inspection", label: "Inspection" },
{ value: "roadTax", label: "Road tax" },
{ value: "warranty", label: "Warranty" },
{ value: "other", label: "Other" },
];
const TYPE_VALUES = ["insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"];
const form = ref({
type: props.doc?.type ?? "insurance",
@@ -78,62 +71,60 @@ function payload() {
</script>
<template>
<Modal :title="isEdit ? 'Edit document' : 'Add document'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.document.editTitle') : t('forms.document.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div>
<label class="dh-label">Type</label>
<label class="dh-label">{{ t("forms.document.type") }}</label>
<select v-model="form.type" class="dh-input">
<option v-for="t in TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
<option v-for="v in TYPE_VALUES" :key="v" :value="v">{{ t(`enums.documentType.${v}`) }}</option>
</select>
</div>
<div>
<label class="dh-label">Title *</label>
<input v-model="form.title" required placeholder="Third-party liability 2026" class="dh-input" />
<label class="dh-label">{{ t("forms.document.title") }}</label>
<input v-model="form.title" required :placeholder="t('forms.document.titlePlaceholder')" class="dh-input" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Provider</label>
<label class="dh-label">{{ t("forms.document.provider") }}</label>
<input v-model="form.provider" placeholder="PZU" class="dh-input" />
</div>
<div>
<label class="dh-label">Policy / certificate no.</label>
<label class="dh-label">{{ t("forms.document.reference") }}</label>
<input v-model="form.reference" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Issued</label>
<label class="dh-label">{{ t("forms.document.issued") }}</label>
<input v-model="form.issueDate" type="date" class="dh-input data" />
</div>
<div>
<label class="dh-label">Renewal date</label>
<label class="dh-label">{{ t("forms.document.renewalDate") }}</label>
<input v-model="form.expiryDate" type="date" class="dh-input data" />
</div>
</div>
<p class="text-xs text-muted">
Leave the renewal date blank for a document that never expires. Setting it adds a reminder automatically.
</p>
<p class="text-xs text-muted">{{ t("forms.document.renewalHint") }}</p>
<div>
<label class="dh-label">Cost</label>
<label class="dh-label">{{ t("forms.document.cost") }}</label>
<input v-model="form.cost" type="number" step="0.01" min="0" class="dh-input data" />
</div>
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="doc" legend="Scan or photo" />
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="doc" :legend="t('forms.document.attachmentLegend')" />
<div>
<label class="dh-label">Notes</label>
<label class="dh-label">{{ t("forms.document.notes") }}</label>
<input v-model="form.notes" class="dh-input" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Add document" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.document.submit") }}
</button>
</div>
</form>
+17 -18
View File
@@ -2,6 +2,7 @@
import { ref, computed } from "vue";
import { api } from "../api";
import { applyAttachment } from "../lib/attachment.js";
import { t, tSplit } from "../i18n";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
@@ -75,66 +76,64 @@ function payload() {
</script>
<template>
<Modal :title="isEdit ? 'Edit refill' : 'Log refill'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.fuel.editTitle') : t('forms.fuel.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Date *</label>
<label class="dh-label">{{ t("forms.fuel.date") }}</label>
<input v-model="form.date" type="date" required class="dh-input data" />
</div>
<div>
<label class="dh-label">Odometer (km) *</label>
<label class="dh-label">{{ t("forms.fuel.odometer") }}</label>
<input v-model="form.km" type="number" min="1" required placeholder="16138" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Litres *</label>
<label class="dh-label">{{ t("forms.fuel.liters") }}</label>
<input v-model="form.liters" type="number" step="0.01" min="0.01" required placeholder="42.5" class="dh-input data" />
</div>
<div>
<label class="dh-label">Total cost</label>
<label class="dh-label">{{ t("forms.fuel.cost") }}</label>
<input v-model="form.cost" type="number" step="0.01" min="0" placeholder="285.00" class="dh-input data" />
</div>
</div>
<p v-if="pricePerLiter" class="text-xs text-muted">
Price per litre: <span class="data text-strong">{{ pricePerLiter }}</span>
{{ tSplit("forms.fuel.pricePerLiter", "price").before
}}<span class="data text-strong">{{ pricePerLiter }}</span>{{ tSplit("forms.fuel.pricePerLiter", "price").after }}
</p>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Tank</legend>
<legend class="eyebrow px-1">{{ t("forms.fuel.tank") }}</legend>
<label class="flex items-center gap-2 py-1 text-sm text-body">
<input type="checkbox" v-model="form.fullTank" class="accent-[var(--accent)]" /> Filled to full
<input type="checkbox" v-model="form.fullTank" class="accent-[var(--accent)]" /> {{ t("forms.fuel.fullTank") }}
</label>
<label class="flex items-center gap-2 py-1 text-sm text-body">
<input type="checkbox" v-model="form.missedFill" class="accent-[var(--accent)]" /> I missed logging a refill before this one
<input type="checkbox" v-model="form.missedFill" class="accent-[var(--accent)]" /> {{ t("forms.fuel.missedFill") }}
</label>
<p class="mt-1.5 text-xs text-muted">
Consumption is measured between full tanks, so partial fills count towards the next full one.
Flagging a missed refill leaves that stretch out of the figures instead of reporting it as unrealistically economical.
</p>
<p class="mt-1.5 text-xs text-muted">{{ t("forms.fuel.tankHint") }}</p>
</fieldset>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Station</label>
<label class="dh-label">{{ t("forms.fuel.station") }}</label>
<input v-model="form.station" placeholder="Orlen" class="dh-input" />
</div>
<div>
<label class="dh-label">Notes</label>
<label class="dh-label">{{ t("forms.fuel.notes") }}</label>
<input v-model="form.notes" class="dh-input" />
</div>
</div>
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" legend="Receipt" />
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" :legend="t('forms.fuel.attachmentLegend')" />
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Log refill" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.fuel.submit") }}
</button>
</div>
</form>
@@ -3,6 +3,7 @@ import { ref, computed } from "vue";
import { api } from "../api";
import { formatMoney } from "../lib/format.js";
import { applyAttachment } from "../lib/attachment.js";
import { t, tSplit } from "../i18n";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
@@ -16,22 +17,8 @@ const isEdit = !!props.entry;
const saving = ref(false);
const error = ref("");
const TYPES = [
{ value: "repair", label: "Repair" },
{ value: "inspection", label: "Inspection" },
{ value: "bodywork", label: "Bodywork" },
{ value: "tyres", label: "Tyres" },
{ value: "diagnostics", label: "Diagnostics" },
{ value: "recall", label: "Recall" },
{ value: "warranty", label: "Warranty work" },
{ value: "other", label: "Other" },
];
const STATUSES = [
{ value: "scheduled", label: "Scheduled" },
{ value: "in_progress", label: "In progress" },
{ value: "completed", label: "Completed" },
];
const TYPE_VALUES = ["repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"];
const STATUS_VALUES = ["scheduled", "in_progress", "completed"];
const form = ref({
date: props.entry ? toDateInput(props.entry.date) : new Date().toISOString().slice(0, 10),
@@ -101,93 +88,94 @@ function payload() {
</script>
<template>
<Modal :title="isEdit ? 'Edit workshop visit' : 'Log workshop visit'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.maintenance.editTitle') : t('forms.maintenance.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Date *</label>
<label class="dh-label">{{ t("forms.maintenance.date") }}</label>
<input v-model="form.date" type="date" required class="dh-input data" />
</div>
<div>
<label class="dh-label">Odometer (km)</label>
<label class="dh-label">{{ t("forms.maintenance.odometer") }}</label>
<input v-model="form.km" type="number" min="0" placeholder="16138" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Type</label>
<label class="dh-label">{{ t("forms.maintenance.type") }}</label>
<select v-model="form.type" class="dh-input">
<option v-for="t in TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
<option v-for="v in TYPE_VALUES" :key="v" :value="v">{{ t(`enums.maintenanceType.${v}`) }}</option>
</select>
</div>
<div>
<label class="dh-label">Status</label>
<label class="dh-label">{{ t("forms.maintenance.status") }}</label>
<select v-model="form.status" class="dh-input">
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
<option v-for="v in STATUS_VALUES" :key="v" :value="v">{{ t(`enums.maintenanceStatus.${v}`) }}</option>
</select>
</div>
</div>
<div>
<label class="dh-label">What was done *</label>
<input v-model="form.description" required placeholder="Replaced alternator and drive belt" class="dh-input" />
<label class="dh-label">{{ t("forms.maintenance.description") }}</label>
<input v-model="form.description" required :placeholder="t('forms.maintenance.descriptionPlaceholder')" class="dh-input" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Workshop</label>
<label class="dh-label">{{ t("forms.maintenance.workshop") }}</label>
<input v-model="form.workshop" placeholder="Auto Serwis Kowalski" class="dh-input" />
</div>
<div>
<label class="dh-label">Location</label>
<label class="dh-label">{{ t("forms.maintenance.location") }}</label>
<input v-model="form.location" placeholder="Kraków" class="dh-input" />
</div>
</div>
<div>
<label class="dh-label">Parts replaced</label>
<input v-model="form.partsUsed" placeholder="Alternator 27060-0T010, belt 90916-02660" class="dh-input" />
<label class="dh-label">{{ t("forms.maintenance.partsUsed") }}</label>
<input v-model="form.partsUsed" :placeholder="t('forms.maintenance.partsUsedPlaceholder')" class="dh-input" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Labour cost</label>
<label class="dh-label">{{ t("forms.maintenance.laborCost") }}</label>
<input v-model="form.laborCost" type="number" step="0.01" min="0" class="dh-input data" />
</div>
<div>
<label class="dh-label">Parts cost</label>
<label class="dh-label">{{ t("forms.maintenance.partsCost") }}</label>
<input v-model="form.partsCost" type="number" step="0.01" min="0" class="dh-input data" />
</div>
</div>
<p v-if="totalCost" class="text-xs text-muted">
Total: <span class="data text-strong">{{ totalCost }}</span>
{{ tSplit("forms.maintenance.total", "total").before
}}<span class="data text-strong">{{ totalCost }}</span>{{ tSplit("forms.maintenance.total", "total").after }}
</p>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Invoice number</label>
<label class="dh-label">{{ t("forms.maintenance.invoiceNumber") }}</label>
<input v-model="form.invoiceNumber" class="dh-input data" />
</div>
<div>
<label class="dh-label">Warranty until</label>
<label class="dh-label">{{ t("forms.maintenance.warrantyUntil") }}</label>
<input v-model="form.warrantyUntil" type="date" class="dh-input data" />
</div>
</div>
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" legend="Invoice" />
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" :legend="t('forms.maintenance.attachmentLegend')" />
<div>
<label class="dh-label">Notes</label>
<label class="dh-label">{{ t("forms.maintenance.notes") }}</label>
<input v-model="form.notes" class="dh-input" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Log visit" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.maintenance.submit") }}
</button>
</div>
</form>
+10 -9
View File
@@ -2,6 +2,7 @@
import { ref } from "vue";
import { api } from "../api";
import { applyAttachment } from "../lib/attachment.js";
import { t } from "../i18n";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
@@ -51,26 +52,26 @@ async function submit() {
</script>
<template>
<Modal :title="isEdit ? 'Edit part' : 'Add part'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.part.editTitle') : t('forms.part.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div>
<label class="dh-label">Part name *</label>
<input v-model="form.name" required placeholder="Oil Filter" class="dh-input" />
<label class="dh-label">{{ t("forms.part.name") }}</label>
<input v-model="form.name" required :placeholder="t('forms.part.namePlaceholder')" class="dh-input" />
</div>
<div>
<label class="dh-label">Part number</label>
<label class="dh-label">{{ t("forms.part.partNumber") }}</label>
<input v-model="form.partNumber" placeholder="04152-YZZA7" class="dh-input data" />
</div>
<div>
<label class="dh-label">Notes</label>
<input v-model="form.notes" placeholder="Fits 20152020 · buy in pairs" class="dh-input" />
<label class="dh-label">{{ t("forms.part.notes") }}</label>
<input v-model="form.notes" :placeholder="t('forms.part.notesPlaceholder')" class="dh-input" />
</div>
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="part" legend="Photo or spec sheet" />
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="part" :legend="t('forms.part.attachmentLegend')" />
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Add part" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.part.submit") }}
</button>
</div>
</form>
@@ -2,6 +2,7 @@
import { ref, computed } from "vue";
import { api } from "../api";
import { formatKm } from "../lib/format.js";
import { t } from "../i18n";
import Modal from "./Modal.vue";
const props = defineProps({
@@ -15,13 +16,7 @@ const isEdit = !!props.reminder;
const saving = ref(false);
const error = ref("");
const TYPES = [
{ value: "maintenance", label: "Maintenance" },
{ value: "document", label: "Document renewal" },
{ value: "service", label: "Service" },
{ value: "inspection", label: "Inspection" },
{ value: "other", label: "Other" },
];
const TYPE_VALUES = ["maintenance", "document", "service", "inspection", "other"];
const form = ref({
title: props.reminder?.title ?? "",
@@ -44,7 +39,7 @@ const isRecurring = computed(() => Number(form.value.repeatDays) > 0 || Number(f
async function submit() {
if (!hasTrigger.value) {
error.value = "Set a due date, a due odometer reading, or both.";
error.value = t("forms.reminder.noTrigger");
return;
}
saving.value = true;
@@ -78,70 +73,65 @@ function payload() {
</script>
<template>
<Modal :title="isEdit ? 'Edit reminder' : 'Add reminder'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.reminder.editTitle') : t('forms.reminder.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div>
<label class="dh-label">Title *</label>
<input v-model="form.title" required placeholder="Swap to winter tyres" class="dh-input" />
<label class="dh-label">{{ t("forms.reminder.title") }}</label>
<input v-model="form.title" required :placeholder="t('forms.reminder.titlePlaceholder')" class="dh-input" />
</div>
<div>
<label class="dh-label">Type</label>
<label class="dh-label">{{ t("forms.reminder.type") }}</label>
<select v-model="form.type" class="dh-input">
<option v-for="t in TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
<option v-for="v in TYPE_VALUES" :key="v" :value="v">{{ t(`enums.reminderType.${v}`) }}</option>
</select>
</div>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Remind me</legend>
<legend class="eyebrow px-1">{{ t("forms.reminder.remindMe") }}</legend>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">On date</label>
<label class="dh-label">{{ t("forms.reminder.onDate") }}</label>
<input v-model="form.dueDate" type="date" class="dh-input data" />
</div>
<div>
<label class="dh-label">At odometer (km)</label>
<label class="dh-label">{{ t("forms.reminder.atOdometer") }}</label>
<input v-model="form.dueKm" type="number" min="0" placeholder="30000" class="dh-input data" />
</div>
</div>
<p class="mt-1.5 text-xs text-muted">
Set either or both with both, whichever comes first wins.
<span v-if="car?.currentKm"> The car is at {{ formatKm(car.currentKm) }} now.</span>
{{ t("forms.reminder.triggerHint") }}
<span v-if="car?.currentKm"> {{ t("forms.reminder.currentKm", { km: formatKm(car.currentKm) }) }}</span>
</p>
</fieldset>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Repeat (optional)</legend>
<legend class="eyebrow px-1">{{ t("forms.reminder.repeat") }}</legend>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Every days</label>
<label class="dh-label">{{ t("forms.reminder.everyDays") }}</label>
<input v-model="form.repeatDays" type="number" min="0" placeholder="365" class="dh-input data" />
</div>
<div>
<label class="dh-label">Every km</label>
<label class="dh-label">{{ t("forms.reminder.everyKm") }}</label>
<input v-model="form.repeatKm" type="number" min="0" placeholder="15000" class="dh-input data" />
</div>
</div>
<p class="mt-1.5 text-xs text-muted">
<template v-if="isRecurring">
Marking this done will roll it forward instead of closing it.
</template>
<template v-else>
Leave blank for a one-off reminder that closes when you mark it done.
</template>
{{ isRecurring ? t("forms.reminder.recurringHint") : t("forms.reminder.oneOffHint") }}
</p>
</fieldset>
<div>
<label class="dh-label">Notes</label>
<label class="dh-label">{{ t("forms.reminder.notes") }}</label>
<input v-model="form.notes" class="dh-input" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving || !hasTrigger" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Add reminder" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.reminder.submit") }}
</button>
</div>
</form>
+13 -12
View File
@@ -3,6 +3,7 @@ import { ref } from "vue";
import { api } from "../api";
import { formatKm } from "../lib/format.js";
import { applyAttachment } from "../lib/attachment.js";
import { t } from "../i18n";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
@@ -62,42 +63,42 @@ async function submit() {
</script>
<template>
<Modal :title="isEdit ? 'Edit service record' : 'Add service record'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.service.editTitle') : t('forms.service.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Date *</label>
<label class="dh-label">{{ t("forms.service.date") }}</label>
<input v-model="form.date" type="date" required class="dh-input data" />
</div>
<div>
<label class="dh-label">Odometer (km)</label>
<label class="dh-label">{{ t("forms.service.odometer") }}</label>
<input v-model="form.km" type="number" placeholder="16138" class="dh-input data" />
</div>
</div>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Changed parts</legend>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedOil" class="accent-[var(--accent)]" /> Oil &amp; Oil filter</label>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedEngineAirFilter" class="accent-[var(--accent)]" /> Engine air filter</label>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedCabinAirFilter" class="accent-[var(--accent)]" /> Cabin air filter</label>
<legend class="eyebrow px-1">{{ t("forms.service.changedParts") }}</legend>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedOil" class="accent-[var(--accent)]" /> {{ t("forms.service.oil") }}</label>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedEngineAirFilter" class="accent-[var(--accent)]" /> {{ t("forms.service.engineFilter") }}</label>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedCabinAirFilter" class="accent-[var(--accent)]" /> {{ t("forms.service.cabinFilter") }}</label>
</fieldset>
<AttachmentField
v-model:file="file"
v-model:remove="removeFile"
:record="service"
legend="Receipt or service-book page"
:legend="t('forms.service.attachmentLegend')"
/>
<div>
<label class="dh-label">Notes</label>
<label class="dh-label">{{ t("forms.service.notes") }}</label>
<input v-model="form.notes" class="dh-input" />
</div>
<p v-if="car" class="text-xs text-muted">
Next service date (+{{ car.serviceIntervalDays }}d) and km (+{{ formatKm(car.serviceIntervalKm) }}) are computed automatically.
{{ t("forms.service.autoHint", { days: car.serviceIntervalDays, km: formatKm(car.serviceIntervalKm) }) }}
</p>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Add service" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.service.submit") }}
</button>
</div>
</form>
+14 -16
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from "vue";
import { api } from "../api";
import { t } from "../i18n";
import Modal from "./Modal.vue";
const props = defineProps({ car: { type: Object, required: true } });
@@ -68,32 +69,29 @@ onMounted(load);
</script>
<template>
<Modal :title="`Share ${car.name}`" @close="emit('close')">
<p class="mb-4 text-sm text-muted">
Give another user access to this car. Read-only lets them view; read &amp; write also lets
them edit the car and its service records and parts.
</p>
<Modal :title="t('forms.share.title', { name: car.name })" @close="emit('close')">
<p class="mb-4 text-sm text-muted">{{ t("forms.share.body") }}</p>
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<!-- Add form -->
<form class="mb-4 flex items-end gap-2" @submit.prevent="addShare">
<div class="flex-1">
<label class="dh-label">User email</label>
<label class="dh-label">{{ t("forms.share.userEmail") }}</label>
<input v-model="email" type="email" required placeholder="person@example.com" class="dh-input" />
</div>
<select v-model="permission" class="dh-input w-auto">
<option value="read">Read-only</option>
<option value="write">Read &amp; write</option>
<option value="read">{{ t("forms.share.read") }}</option>
<option value="write">{{ t("forms.share.write") }}</option>
</select>
<button type="submit" :disabled="submitting" class="dh-btn dh-btn-primary">Share</button>
<button type="submit" :disabled="submitting" class="dh-btn dh-btn-primary">{{ t("forms.share.submit") }}</button>
</form>
<!-- Current shares -->
<div>
<h3 class="mb-2 text-sm font-semibold text-strong">People with access</h3>
<p v-if="loading" class="text-sm text-muted">Loading</p>
<p v-else-if="shares.length === 0" class="text-sm text-muted">Not shared with anyone yet.</p>
<h3 class="mb-2 text-sm font-semibold text-strong">{{ t("forms.share.peopleWithAccess") }}</h3>
<p v-if="loading" class="text-sm text-muted">{{ t("common.loading") }}</p>
<p v-else-if="shares.length === 0" class="text-sm text-muted">{{ t("forms.share.notShared") }}</p>
<ul v-else class="divide-y divide-subtle">
<li v-for="s in shares" :key="s.user.id" class="flex items-center justify-between gap-2 py-2">
<div class="min-w-0">
@@ -102,17 +100,17 @@ onMounted(load);
</div>
<div class="flex items-center gap-2">
<select :value="s.permission" @change="setPermission(s, $event.target.value)" class="dh-input w-auto !py-1 !text-xs">
<option value="read">Read-only</option>
<option value="write">Read &amp; write</option>
<option value="read">{{ t("forms.share.read") }}</option>
<option value="write">{{ t("forms.share.write") }}</option>
</select>
<button class="text-xs font-medium text-danger hover:underline" @click="removeShare(s)">Remove</button>
<button class="text-xs font-medium text-danger hover:underline" @click="removeShare(s)">{{ t("common.remove") }}</button>
</div>
</li>
</ul>
</div>
<div class="mt-6 flex justify-end">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Done</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.done") }}</button>
</div>
</Modal>
</template>
@@ -3,6 +3,7 @@ import { ref, computed } from "vue";
import { api } from "../api";
import { formatDate } from "../lib/format.js";
import { applyAttachment } from "../lib/attachment.js";
import { t, tSplit } from "../i18n";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
@@ -75,44 +76,43 @@ async function submit() {
</script>
<template>
<Modal :title="isEdit ? 'Edit technical check' : 'Add technical check'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.technical.editTitle') : t('forms.technical.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Check date *</label>
<label class="dh-label">{{ t("forms.technical.date") }}</label>
<input v-model="form.date" type="date" required class="dh-input data" />
</div>
<div>
<label class="dh-label">Result *</label>
<label class="dh-label">{{ t("forms.technical.result") }}</label>
<select v-model="form.result" class="dh-input">
<option value="passed">Passed</option>
<option value="failed">Failed</option>
<option value="passed">{{ t("forms.technical.passed") }}</option>
<option value="failed">{{ t("forms.technical.failed") }}</option>
</select>
</div>
</div>
<div>
<label class="dh-label">Valid until</label>
<label class="dh-label">{{ t("forms.technical.validUntil") }}</label>
<input v-model="form.validUntil" type="date" class="dh-input data" />
<p v-if="form.result === 'failed'" class="mt-1 text-xs text-muted">
A failed check certifies nothing, so no next date is derived from it.
{{ t("forms.technical.failedHint") }}
</p>
<p v-else-if="derivedNext" class="mt-1 text-xs text-muted">
Leave blank to use the car's interval (+{{ derivedNext.days }}d
<span class="data">{{ derivedNext.date }}</span>). Enter the date on the certificate
when it differs.
{{ tSplit("forms.technical.derivedHint", "date", { days: derivedNext.days }).before
}}<span class="data">{{ derivedNext.date }}</span>{{ tSplit("forms.technical.derivedHint", "date", { days: derivedNext.days }).after }}
</p>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Cost</label>
<label class="dh-label">{{ t("forms.technical.cost") }}</label>
<input v-model="form.cost" type="number" step="0.01" min="0" placeholder="99" class="dh-input data" />
</div>
<div>
<label class="dh-label">Station</label>
<input v-model="form.station" placeholder="Stacja Kontroli Pojazdów" class="dh-input" />
<label class="dh-label">{{ t("forms.technical.station") }}</label>
<input v-model="form.station" :placeholder="t('forms.technical.stationPlaceholder')" class="dh-input" />
</div>
</div>
@@ -120,18 +120,18 @@ async function submit() {
v-model:file="file"
v-model:remove="removeFile"
:record="check"
legend="Inspection certificate"
:legend="t('forms.technical.attachmentLegend')"
/>
<div>
<label class="dh-label">Notes</label>
<label class="dh-label">{{ t("forms.technical.notes") }}</label>
<input v-model="form.notes" class="dh-input" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Add check" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.technical.submit") }}
</button>
</div>
</form>
+628
View File
@@ -0,0 +1,628 @@
{
"errors": {
"sessionExpired": "Sessionen er udløbet — log ind igen."
},
"common": {
"cancel": "Annuller",
"save": "Gem",
"saveChanges": "Gem ændringer",
"saving": "Gemmer…",
"saved": "Gemt ✓",
"loading": "Indlæser…",
"edit": "Rediger",
"remove": "Fjern",
"delete": "Slet",
"done": "Færdig",
"undo": "Fortryd",
"download": "Download",
"empty": "—",
"yes": "Ja",
"no": "Nej"
},
"nav": {
"garage": "Garage",
"settings": "Indstillinger",
"users": "Brugere",
"lightMode": "Lys tilstand",
"darkMode": "Mørk tilstand",
"signedIn": "Logget ind",
"logOut": "Log ud"
},
"login": {
"title": "Log ind",
"tagline": "Styr på din bil.",
"email": "E-mail",
"password": "Adgangskode",
"showPassword": "Vis adgangskode",
"hidePassword": "Skjul adgangskode",
"submit": "Log ind",
"submitting": "Logger ind…",
"failed": "Login mislykkedes",
"serverSettings": "Serverindstillinger",
"apiServerUrl": "API-serverens adresse",
"leaveBlank": "Lad feltet stå tomt for at bruge standarden ({url}).",
"resetToDefault": "Nulstil til standard"
},
"dashboard": {
"eyebrow": "Garage",
"title": "Dine biler",
"subtitle": "Serviceoverblik og servicehistorik.",
"addCar": "Tilføj bil",
"empty": "Ingen biler endnu. Klik på {action} for at komme i gang.",
"shared": "Delt",
"sharedReadOnly": "Delt · skrivebeskyttet",
"serviceLife": "Serviceinterval brugt",
"lastService": "Seneste service",
"odometer": "Kilometerstand",
"nextDue": "Næste service",
"nextDueKm": "Næste service (km)",
"serviceRecords": {
"one": "{n} servicepost",
"other": "{n} serviceposter"
}
},
"admin": {
"eyebrow": "Administration",
"title": "Brugere",
"subtitleAll": "Konti på tværs af alle organisationer.",
"subtitleOrg": "Konti i din organisation.",
"subtitleOrgsNote": "Organisationer tildeles i API-panelet.",
"addUser": "Tilføj bruger",
"colEmail": "E-mail",
"colName": "Navn",
"colOrganization": "Organisation",
"colRole": "Rolle",
"colCreated": "Oprettet",
"you": "(dig)",
"resetPassword": "Nulstil adgangskode",
"confirmDelete": "Slet {name}? Dette kan ikke fortrydes.",
"cantDeleteSelf": "Du kan ikke slette din egen konto.",
"onlySuperadminDeletes": "Kun en superadministrator kan slette en superadministrator.",
"cantChangeOwnRole": "Du kan ikke ændre din egen rolle.",
"onlySuperadminEdits": "Kun en superadministrator kan redigere en superadministrator.",
"createTitle": "Tilføj en bruger",
"emailRequired": "E-mail *",
"passwordRequired": "Adgangskode *",
"minChars": "(mindst 8)",
"creating": "Opretter…",
"createUser": "Opret bruger",
"resetTitle": "Nulstil adgangskode — {email}",
"newPassword": "Ny adgangskode",
"setPassword": "Angiv adgangskode",
"roles": {
"user": "bruger",
"admin": "administrator",
"superadmin": "superadministrator"
}
},
"settings": {
"eyebrow": "Konto",
"title": "Indstillinger",
"subtitle": "Administrer din konto, udseende og dine data.",
"account": {
"title": "Konto",
"name": "Navn",
"email": "E-mail",
"verified": "Bekræftet",
"notVerified": "Ikke bekræftet",
"resendVerification": "Send bekræftelsesmail igen",
"sending": "Sender…",
"verificationRequested": "Bekræftelsesmail anmodet.",
"changePassword": "Skift adgangskode",
"currentPassword": "Nuværende adgangskode",
"newPassword": "Ny adgangskode",
"confirmNewPassword": "Bekræft ny adgangskode",
"mismatchYet": "Adgangskoderne stemmer ikke overens endnu.",
"tooShort": "Den nye adgangskode skal være på mindst 8 tegn.",
"mismatch": "Den nye adgangskode og bekræftelsen stemmer ikke overens.",
"updating": "Opdaterer…",
"passwordUpdated": "Adgangskode opdateret ✓",
"updatePassword": "Opdater adgangskode"
},
"appearance": {
"title": "Udseende",
"theme": "Tema",
"themeLight": "lyst",
"themeDark": "mørkt",
"themeSystem": "system",
"language": "Sprog",
"languageHint": "Appens tekst samt navne på måneder og dage.",
"languageFallbackHint": "Dette sprog er endnu ikke oversat — appens tekst forbliver på engelsk.",
"region": "Region",
"regionHint": "Tal- og valutaformat.",
"dateFormat": "Datoformat",
"dateExample": "Eksempel: {example}",
"currency": "Valuta",
"currencyExample": "Eksempel: {example} — kun visning, ingen beløb omregnes.",
"fontSize": "Skriftstørrelse",
"fontSmall": "lille",
"fontMedium": "mellem",
"fontLarge": "stor"
},
"profile": {
"title": "Profil",
"avatarAlt": "Profilbillede",
"uploading": "Uploader…",
"uploadPhoto": "Upload billede",
"bio": "Om mig",
"bioPlaceholder": "En kort note, som andre i din husstand kan se.",
"saveBio": "Gem beskrivelse"
},
"privacy": {
"title": "Privatliv og sikkerhed",
"signOut": "Log ud",
"body": "Tofaktorgodkendelse er ikke tilgængelig endnu. Sessioner bygger på tokens udstedt af serveren, som udløber af sig selv, så at logge ud her afslutter kun sessionen på denne enhed — der er ingen liste over enheder at tilbagekalde fra. For at logge alle enheder ud skal du skifte din adgangskode ovenfor."
},
"advanced": {
"title": "Avanceret",
"exportTitle": "Eksportér dine data",
"exportBody": "Download din profil samt alle biler, serviceposter og reservedele som JSON.",
"preparing": "Forbereder…",
"exportAction": "Eksportér data",
"importTitle": "Importér dine data",
"importBody": "Tilføj biler fra en tidligere eksporteret JSON-fil. Dette opretter nye poster — intet flettes eller overskrives.",
"importing": "Importerer…",
"importAction": "Importér data",
"notJson": "Filen er ikke gyldig JSON.",
"notExport": "Filen ligner ikke en DriverVault-eksport (der mangler en \"cars\"-liste).",
"confirmImport": "Importér {count} bil(er) fra denne fil? Dette tilføjer nye poster — eksisterende biler flettes eller overskrives ikke.",
"imported": "Importerede {cars} bil(er), {services} servicepost(er), {parts} reservedel(e)."
},
"danger": {
"title": "Farezone",
"body": "Sletning af din konto fjerner dit login og din profil. Det sletter ikke husstandens delte biler eller servicehistorik. Der er 3 dages betænkningstid, før sletningen er endelig, og du kan annullere når som helst inden da.",
"deleteAccount": "Slet min konto",
"typeToConfirm": "Skriv {email} for at bekræfte",
"requesting": "Anmoder…",
"requestDeletion": "Anmod om sletning",
"requestedOn": "Sletning af konto anmodet den {date}.",
"canStillCancel": "Du kan stadig annullere — det bliver permanent efter 3 dages betænkningstid.",
"cooldownPassed": "Betænkningstiden er udløbet. Du kan nu gennemføre sletningen.",
"cancelRequest": "Annuller anmodning om sletning",
"finalize": "Slet min konto permanent",
"confirmFinalize": "Dette sletter din konto permanent. Det kan ikke fortrydes. Fortsæt?"
}
},
"car": {
"allCars": "← Alle biler",
"share": "Del",
"shared": "Delt",
"sharedReadOnly": "Delt · skrivebeskyttet",
"tabs": {
"info": "Oplysninger",
"services": "Servicehistorik",
"technical": "Synshistorik",
"maintenance": "Værksted",
"fuel": "Brændstof",
"documents": "Dokumenter",
"parts": "Reservedelskatalog",
"reminders": "Påmindelser"
},
"info": {
"oilSpec": "Motorolie-specifikation",
"transmissionOil": "Gearolie",
"differentialOil": "Differentialeolie",
"brakeFluid": "Bremsevæske",
"coolant": "Kølervæske",
"odometer": "Kilometerstand",
"serviceInterval": "Serviceinterval",
"nextDue": "Næste service",
"registrationPlate": "Nummerplade",
"registrationCountry": "Registreringsland",
"vin": "Stelnummer",
"fuelType": "Brændstoftype",
"buildDate": "Produktionsdato",
"firstRegistration": "Første registrering"
},
"services": {
"title": "Servicehistorik",
"add": "Tilføj service",
"empty": "Ingen serviceposter endnu.",
"colDate": "Dato",
"colKm": "Km",
"colNextDate": "Næste dato",
"colNextKm": "Næste km",
"colOil": "Olie og oliefilter",
"colEngineFilter": "Luftfilter",
"colCabinFilter": "Kabinefilter",
"colNotes": "Noter",
"colFile": "Fil",
"confirmDelete": "Slet denne servicepost?"
},
"technical": {
"title": "Synshistorik",
"subtitle": "Lovpligtige syn. Gentages alene efter tid, uanset hvad kilometerstanden viser.",
"add": "Tilføj syn",
"empty": "Ingen syn endnu.",
"colDate": "Dato",
"colResult": "Resultat",
"colNextCheck": "Næste syn",
"colStatus": "Status",
"colStation": "Synssted",
"colCost": "Pris",
"colNotes": "Noter",
"colFile": "Fil",
"passed": "Godkendt",
"failed": "Ikke godkendt",
"confirmDelete": "Slet dette syn?"
},
"maintenance": {
"title": "Værksted",
"subtitle": "Værkstedsbesøg og reparationer. Almindelig service hører under Servicehistorik.",
"add": "Registrér besøg",
"empty": "Ingen værkstedsbesøg registreret endnu.",
"colDate": "Dato",
"colKm": "Km",
"colType": "Type",
"colWork": "Udført arbejde",
"colWorkshop": "Værksted",
"colStatus": "Status",
"colCost": "Pris",
"colFile": "Fil",
"underWarranty": "Under garanti · {days} dage tilbage",
"confirmDelete": "Slet dette værkstedsbesøg?"
},
"fuel": {
"title": "Brændstof",
"subtitle": "Forbruget måles mellem fulde tanke.",
"add": "Registrér tankning",
"empty": "Ingen tankninger registreret endnu.",
"average": "Gennemsnit",
"best": "Bedste",
"worst": "Værste",
"costPerKm": "Pris pr. km",
"refills": "Tankninger",
"totalLiters": "Liter i alt",
"totalSpent": "Brugt i alt",
"trackedDistance": "Målt distance",
"avgPrice": "Gns. pris {price}/L",
"needTwoTanks": "Registrér mindst to fulde tanke for at se forbrugstal.",
"colDate": "Dato",
"colKm": "Km",
"colLiters": "Liter",
"colCost": "Pris",
"colPerLiter": "Pr. liter",
"colDistance": "Distance",
"colConsumption": "Forbrug",
"colStation": "Tankstation",
"colFile": "Fil",
"partial": "delvis",
"gap": "hul",
"confirmDelete": "Slet denne tankning?"
},
"documents": {
"title": "Dokumenter",
"subtitle": "Forsikring, miljøattester og andre papirer med fornyelsesdatoer.",
"add": "Tilføj dokument",
"empty": "Ingen dokumenter endnu.",
"colType": "Type",
"colTitle": "Titel",
"colProvider": "Udbyder",
"colIssued": "Udstedt",
"colRenewal": "Fornyelse",
"colStatus": "Status",
"colFile": "Fil",
"confirmDelete": "Slet dette dokument?"
},
"reminders": {
"title": "Påmindelser",
"subtitle": "Påmindelser om fornyelse og service tilføjes automatisk ud fra dine dokumenter og din servicehistorik.",
"add": "Tilføj påmindelse",
"empty": "Intet at blive mindet om endnu.",
"automatic": "Automatisk",
"repeats": "Gentages",
"at": "ved {km}",
"doneRollForward": "Færdig · flyt frem",
"markDone": "Markér som færdig",
"reopen": "Genåbn",
"confirmDelete": "Slet denne påmindelse?"
},
"parts": {
"title": "Reservedelskatalog",
"add": "Tilføj reservedel",
"empty": "Ingen reservedele endnu.",
"colPart": "Reservedel",
"colPartNumber": "Varenummer",
"colNotes": "Noter",
"colFile": "Fil",
"confirmDelete": "Slet denne reservedel?"
},
"delete": {
"title": "Slet denne bil?",
"body": "Dette sletter {name} permanent sammen med alt, der er registreret på den — {services}, {maintenance}, {fuel}, {documents} og {parts}. Det kan ikke fortrydes.",
"services": {
"one": "{n} servicepost",
"other": "{n} serviceposter"
},
"maintenance": {
"one": "{n} værkstedsbesøg",
"other": "{n} værkstedsbesøg"
},
"fuel": {
"one": "{n} tankning",
"other": "{n} tankninger"
},
"documents": {
"one": "{n} dokument",
"other": "{n} dokumenter"
},
"parts": {
"one": "{n} reservedel",
"other": "{n} reservedele"
},
"typeToConfirm": "Skriv {name} for at bekræfte",
"deleting": "Sletter…",
"confirm": "Slet permanent"
}
},
"attachment": {
"legend": "Vedhæftet fil",
"hint": "PDF eller billede, op til 10 MB.",
"attached": "Vedhæftet: {name}",
"willBeRemoved": "Den vedhæftede fil fjernes, når der gemmes."
},
"forms": {
"car": {
"addTitle": "Tilføj en bil",
"editTitle": "Rediger bil",
"name": "Navn *",
"make": "Mærke",
"model": "Model",
"year": "Årgang",
"registration": "Nummerplade",
"registrationCountry": "Registreringsland",
"registrationCountryPlaceholder": "Danmark",
"vin": "Stelnummer",
"vinPlaceholder": "Køretøjets stelnummer",
"fuelType": "Brændstoftype",
"buildDate": "Produktionsdato",
"firstRegistration": "Første registrering",
"oilSpec": "Motorolie-specifikation",
"currentKm": "Nuværende kilometerstand (km)",
"transmissionOilSpec": "Gearolie-specifikation",
"differentialOilSpec": "Differentialeolie-specifikation",
"brakeFluidSpec": "Bremsevæske-specifikation",
"coolantSpec": "Kølervæske-specifikation",
"serviceIntervalDays": "Serviceinterval (dage)",
"serviceIntervalKm": "Serviceinterval (km)",
"technicalCheckIntervalDays": "Synsinterval (dage)",
"technicalCheckHint": "Udfylder på forhånd hvert syns næste forfaldsdato. Ethvert syn kan tilsidesætte den med datoen på attesten.",
"submit": "Tilføj bil"
},
"service": {
"addTitle": "Tilføj servicepost",
"editTitle": "Rediger servicepost",
"date": "Dato *",
"odometer": "Kilometerstand (km)",
"changedParts": "Udskiftede dele",
"oil": "Olie og oliefilter",
"engineFilter": "Luftfilter",
"cabinFilter": "Kabinefilter",
"attachmentLegend": "Kvittering eller side fra servicebogen",
"notes": "Noter",
"autoHint": "Næste servicedato (+{days} dage) og km (+{km}) beregnes automatisk.",
"submit": "Tilføj service"
},
"technical": {
"addTitle": "Tilføj syn",
"editTitle": "Rediger syn",
"date": "Synsdato *",
"result": "Resultat *",
"passed": "Godkendt",
"failed": "Ikke godkendt",
"validUntil": "Gyldig til",
"failedHint": "Et ikke-godkendt syn attesterer ingenting, så der udledes ingen næste dato af det.",
"derivedHint": "Lad feltet stå tomt for at bruge bilens interval (+{days} dage → {date}). Indtast datoen på attesten, hvis den afviger.",
"cost": "Pris",
"station": "Synssted",
"stationPlaceholder": "Synshal",
"attachmentLegend": "Synsattest",
"notes": "Noter",
"submit": "Tilføj syn"
},
"part": {
"addTitle": "Tilføj reservedel",
"editTitle": "Rediger reservedel",
"name": "Reservedelens navn *",
"namePlaceholder": "Oliefilter",
"partNumber": "Varenummer",
"notes": "Noter",
"notesPlaceholder": "Passer til 20152020 · køb parvis",
"attachmentLegend": "Billede eller datablad",
"submit": "Tilføj reservedel"
},
"fuel": {
"addTitle": "Registrér tankning",
"editTitle": "Rediger tankning",
"date": "Dato *",
"odometer": "Kilometerstand (km) *",
"liters": "Liter *",
"cost": "Samlet pris",
"pricePerLiter": "Pris pr. liter: {price}",
"tank": "Tank",
"fullTank": "Fyldt helt op",
"missedFill": "Jeg glemte at registrere en tankning før denne",
"tankHint": "Forbruget måles mellem fulde tanke, så delvise tankninger tæller med i den næste fulde. At markere en glemt tankning holder den strækning ude af tallene i stedet for at vise et urealistisk lavt forbrug.",
"station": "Tankstation",
"notes": "Noter",
"attachmentLegend": "Kvittering",
"submit": "Registrér tankning"
},
"maintenance": {
"addTitle": "Registrér værkstedsbesøg",
"editTitle": "Rediger værkstedsbesøg",
"date": "Dato *",
"odometer": "Kilometerstand (km)",
"type": "Type",
"status": "Status",
"description": "Hvad blev der lavet *",
"descriptionPlaceholder": "Udskiftede generator og drivrem",
"workshop": "Værksted",
"location": "Sted",
"partsUsed": "Udskiftede dele",
"partsUsedPlaceholder": "Generator 27060-0T010, rem 90916-02660",
"laborCost": "Arbejdsløn",
"partsCost": "Pris for dele",
"total": "I alt: {total}",
"invoiceNumber": "Fakturanummer",
"warrantyUntil": "Garanti til",
"attachmentLegend": "Faktura",
"notes": "Noter",
"submit": "Registrér besøg"
},
"document": {
"addTitle": "Tilføj dokument",
"editTitle": "Rediger dokument",
"type": "Type",
"title": "Titel *",
"titlePlaceholder": "Ansvarsforsikring 2026",
"provider": "Udbyder",
"reference": "Police- / attestnummer",
"issued": "Udstedt",
"renewalDate": "Fornyelsesdato",
"renewalHint": "Lad fornyelsesdatoen stå tom for et dokument, der aldrig udløber. Angives den, tilføjes der automatisk en påmindelse.",
"cost": "Pris",
"attachmentLegend": "Scan eller billede",
"notes": "Noter",
"submit": "Tilføj dokument"
},
"reminder": {
"addTitle": "Tilføj påmindelse",
"editTitle": "Rediger påmindelse",
"title": "Titel *",
"titlePlaceholder": "Skift til vinterdæk",
"type": "Type",
"remindMe": "Mind mig om",
"onDate": "På dato",
"atOdometer": "Ved kilometerstand (km)",
"triggerHint": "Angiv det ene eller begge — med begge gælder det, der indtræffer først.",
"currentKm": "Bilen står på {km} nu.",
"repeat": "Gentagelse (valgfrit)",
"everyDays": "Hver … dage",
"everyKm": "Hver … km",
"recurringHint": "Markeres den som færdig, flyttes den frem i stedet for at blive lukket.",
"oneOffHint": "Lad feltet stå tomt for en engangspåmindelse, der lukkes, når du markerer den som færdig.",
"notes": "Noter",
"noTrigger": "Angiv en forfaldsdato, en kilometerstand eller begge.",
"submit": "Tilføj påmindelse"
},
"share": {
"title": "Del {name}",
"body": "Giv en anden bruger adgang til denne bil. Skrivebeskyttet giver adgang til at se; læs og skriv giver også adgang til at redigere bilen samt dens serviceposter og reservedele.",
"userEmail": "Brugerens e-mail",
"read": "Skrivebeskyttet",
"write": "Læs og skriv",
"submit": "Del",
"peopleWithAccess": "Personer med adgang",
"notShared": "Endnu ikke delt med nogen."
}
},
"enums": {
"fuelType": {
"petrol": "Benzin",
"petrol_lpg": "Benzin + LPG",
"diesel": "Diesel",
"diesel_lpg": "Diesel + LPG",
"hybrid": "Hybrid",
"electric": "El",
"hydrogen": "Brint"
},
"maintenanceType": {
"repair": "Reparation",
"inspection": "Eftersyn",
"bodywork": "Karrosseri",
"tyres": "Dæk",
"diagnostics": "Fejlsøgning",
"recall": "Tilbagekaldelse",
"warranty": "Garantiarbejde",
"other": "Andet"
},
"maintenanceStatus": {
"scheduled": "Planlagt",
"in_progress": "I gang",
"completed": "Fuldført"
},
"documentType": {
"insurance": "Forsikring",
"pollution": "Miljøattest",
"registration": "Registreringsattest",
"inspection": "Eftersyn",
"roadTax": "Vægtafgift",
"warranty": "Garanti",
"other": "Andet"
},
"reminderTypeShort": {
"maintenance": "Vedligehold",
"document": "Dokument",
"service": "Service",
"inspection": "Eftersyn",
"other": "Andet"
},
"reminderType": {
"maintenance": "Vedligehold",
"document": "Fornyelse af dokument",
"service": "Service",
"inspection": "Eftersyn",
"other": "Andet"
}
},
"status": {
"noData": "Ingen data",
"serviceOverdueDays": "Service overskredet med {days} d",
"dueInDays": "Forfalder om {days} d",
"okDays": "OK · {days} d",
"noKm": "Ingen km",
"serviceOverdueKm": "Service overskredet med {km} km",
"inKm": "Om {km} km",
"kmLeft": "{km} km tilbage",
"expiredAgo": "Udløb for {days} d siden",
"expiresToday": "Udløber i dag",
"renewInDays": "Forny om {days} d",
"validDays": "Gyldig · {days} d",
"noExpiry": "Udløber ikke",
"done": "Færdig",
"noTrigger": "Ingen udløser",
"overdue": "Overskredet",
"overdueBy": "Overskredet {parts}",
"dueIn": "Forfalder om {parts}",
"upcoming": "Kommende",
"today": "i dag",
"days": "{days} d",
"km": "{km} km"
}
}
+628
View File
@@ -0,0 +1,628 @@
{
"errors": {
"sessionExpired": "Session expired — please log in again."
},
"common": {
"cancel": "Cancel",
"save": "Save",
"saveChanges": "Save changes",
"saving": "Saving…",
"saved": "Saved ✓",
"loading": "Loading…",
"edit": "Edit",
"remove": "Remove",
"delete": "Delete",
"done": "Done",
"undo": "Undo",
"download": "Download",
"empty": "—",
"yes": "Yes",
"no": "No"
},
"nav": {
"garage": "Garage",
"settings": "Settings",
"users": "Users",
"lightMode": "Light mode",
"darkMode": "Dark mode",
"signedIn": "Signed in",
"logOut": "Log out"
},
"login": {
"title": "Sign in",
"tagline": "Your car, on track.",
"email": "Email",
"password": "Password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"submit": "Sign in",
"submitting": "Signing in…",
"failed": "Login failed",
"serverSettings": "Server settings",
"apiServerUrl": "API server URL",
"leaveBlank": "Leave blank to use the default ({url}).",
"resetToDefault": "Reset to default"
},
"dashboard": {
"eyebrow": "Garage",
"title": "Your cars",
"subtitle": "Maintenance overview and service history.",
"addCar": "Add car",
"empty": "No cars yet. Click {action} to get started.",
"shared": "Shared",
"sharedReadOnly": "Shared · read-only",
"serviceLife": "Service life",
"lastService": "Last service",
"odometer": "Odometer",
"nextDue": "Next due",
"nextDueKm": "Next due km",
"serviceRecords": {
"one": "{n} service record",
"other": "{n} service records"
}
},
"admin": {
"eyebrow": "Admin",
"title": "Users",
"subtitleAll": "Accounts across every organization.",
"subtitleOrg": "Accounts in your organization.",
"subtitleOrgsNote": "Organizations are assigned in the API panel.",
"addUser": "Add user",
"colEmail": "Email",
"colName": "Name",
"colOrganization": "Organization",
"colRole": "Role",
"colCreated": "Created",
"you": "(you)",
"resetPassword": "Reset password",
"confirmDelete": "Delete {name}? This cannot be undone.",
"cantDeleteSelf": "You can't delete your own account.",
"onlySuperadminDeletes": "Only a superadmin can delete a superadmin.",
"cantChangeOwnRole": "You can't change your own role.",
"onlySuperadminEdits": "Only a superadmin can edit a superadmin.",
"createTitle": "Add a user",
"emailRequired": "Email *",
"passwordRequired": "Password *",
"minChars": "(min 8)",
"creating": "Creating…",
"createUser": "Create user",
"resetTitle": "Reset password — {email}",
"newPassword": "New password",
"setPassword": "Set password",
"roles": {
"user": "user",
"admin": "admin",
"superadmin": "superadmin"
}
},
"settings": {
"eyebrow": "Account",
"title": "Settings",
"subtitle": "Manage your account, appearance, and data.",
"account": {
"title": "Account",
"name": "Name",
"email": "Email",
"verified": "Verified",
"notVerified": "Not verified",
"resendVerification": "Resend verification email",
"sending": "Sending…",
"verificationRequested": "Verification email requested.",
"changePassword": "Change password",
"currentPassword": "Current password",
"newPassword": "New password",
"confirmNewPassword": "Confirm new password",
"mismatchYet": "Passwords don't match yet.",
"tooShort": "New password must be at least 8 characters.",
"mismatch": "New password and confirmation don't match.",
"updating": "Updating…",
"passwordUpdated": "Password updated ✓",
"updatePassword": "Update password"
},
"appearance": {
"title": "Appearance",
"theme": "Theme",
"themeLight": "light",
"themeDark": "dark",
"themeSystem": "system",
"language": "Language",
"languageHint": "App text, and the names of months and days.",
"languageFallbackHint": "This language isn't translated yet — the app text stays in English.",
"region": "Region",
"regionHint": "Number and currency layout.",
"dateFormat": "Date format",
"dateExample": "Example: {example}",
"currency": "Currency",
"currencyExample": "Example: {example} — display only, no amounts are converted.",
"fontSize": "Font size",
"fontSmall": "small",
"fontMedium": "medium",
"fontLarge": "large"
},
"profile": {
"title": "Profile",
"avatarAlt": "Avatar",
"uploading": "Uploading…",
"uploadPhoto": "Upload photo",
"bio": "Bio",
"bioPlaceholder": "A short note visible to other people in your household.",
"saveBio": "Save bio"
},
"privacy": {
"title": "Privacy & security",
"signOut": "Sign out",
"body": "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."
},
"advanced": {
"title": "Advanced",
"exportTitle": "Export your data",
"exportBody": "Download your profile and all cars, service records, and parts as JSON.",
"preparing": "Preparing…",
"exportAction": "Export data",
"importTitle": "Import your data",
"importBody": "Add cars from a previously exported JSON file. This creates new records — it doesn't merge with or overwrite anything existing.",
"importing": "Importing…",
"importAction": "Import data",
"notJson": "That file isn't valid JSON.",
"notExport": "That file doesn't look like a DriverVault export (missing a \"cars\" list).",
"confirmImport": "Import {count} car(s) from this file? This adds new records — it does not merge with or overwrite existing cars.",
"imported": "Imported {cars} car(s), {services} service record(s), {parts} part(s)."
},
"danger": {
"title": "Danger zone",
"body": "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.",
"deleteAccount": "Delete my account",
"typeToConfirm": "Type {email} to confirm",
"requesting": "Requesting…",
"requestDeletion": "Request deletion",
"requestedOn": "Account deletion requested on {date}.",
"canStillCancel": "You can still cancel — it becomes permanent after the 3-day cooldown.",
"cooldownPassed": "The cooldown has passed. You can now finalize the deletion.",
"cancelRequest": "Cancel deletion request",
"finalize": "Permanently delete my account",
"confirmFinalize": "This permanently deletes your account. This cannot be undone. Continue?"
}
},
"car": {
"allCars": "← All cars",
"share": "Share",
"shared": "Shared",
"sharedReadOnly": "Shared · read-only",
"tabs": {
"info": "Information",
"services": "Service history",
"technical": "Technical check history",
"maintenance": "Maintenance",
"fuel": "Fuel",
"documents": "Documents",
"parts": "Parts catalog",
"reminders": "Reminders"
},
"info": {
"oilSpec": "Engine oil spec",
"transmissionOil": "Transmission oil",
"differentialOil": "Differential oil",
"brakeFluid": "Brake fluid",
"coolant": "Coolant",
"odometer": "Odometer",
"serviceInterval": "Service interval",
"nextDue": "Next due",
"registrationPlate": "Registration plate",
"registrationCountry": "Registration country",
"vin": "VIN",
"fuelType": "Fuel type",
"buildDate": "Build date",
"firstRegistration": "First registration"
},
"services": {
"title": "Service history",
"add": "Add service",
"empty": "No service records yet.",
"colDate": "Date",
"colKm": "Km",
"colNextDate": "Next date",
"colNextKm": "Next km",
"colOil": "Oil & Oil filter",
"colEngineFilter": "Engine air filter",
"colCabinFilter": "Cabin air filter",
"colNotes": "Notes",
"colFile": "File",
"confirmDelete": "Delete this service record?"
},
"technical": {
"title": "Technical check history",
"subtitle": "Mandatory roadworthiness inspections. Recurs on time alone, whatever the odometer reads.",
"add": "Add check",
"empty": "No technical checks yet.",
"colDate": "Date",
"colResult": "Result",
"colNextCheck": "Next check",
"colStatus": "Status",
"colStation": "Station",
"colCost": "Cost",
"colNotes": "Notes",
"colFile": "File",
"passed": "Passed",
"failed": "Failed",
"confirmDelete": "Delete this technical check?"
},
"maintenance": {
"title": "Maintenance",
"subtitle": "Workshop visits and repairs. Routine servicing lives under Service history.",
"add": "Log visit",
"empty": "No workshop visits logged yet.",
"colDate": "Date",
"colKm": "Km",
"colType": "Type",
"colWork": "Work done",
"colWorkshop": "Workshop",
"colStatus": "Status",
"colCost": "Cost",
"colFile": "File",
"underWarranty": "Under warranty · {days}d left",
"confirmDelete": "Delete this workshop visit?"
},
"fuel": {
"title": "Fuel",
"subtitle": "Consumption is measured between full tanks.",
"add": "Log refill",
"empty": "No refills logged yet.",
"average": "Average",
"best": "Best",
"worst": "Worst",
"costPerKm": "Cost per km",
"refills": "Refills",
"totalLiters": "Total litres",
"totalSpent": "Total spent",
"trackedDistance": "Tracked distance",
"avgPrice": "Avg. price {price}/L",
"needTwoTanks": "Log at least two full tanks to see consumption figures.",
"colDate": "Date",
"colKm": "Km",
"colLiters": "Litres",
"colCost": "Cost",
"colPerLiter": "Per litre",
"colDistance": "Distance",
"colConsumption": "Consumption",
"colStation": "Station",
"colFile": "File",
"partial": "partial",
"gap": "gap",
"confirmDelete": "Delete this refill?"
},
"documents": {
"title": "Documents",
"subtitle": "Insurance, pollution certificates and other paperwork with renewal dates.",
"add": "Add document",
"empty": "No documents yet.",
"colType": "Type",
"colTitle": "Title",
"colProvider": "Provider",
"colIssued": "Issued",
"colRenewal": "Renewal",
"colStatus": "Status",
"colFile": "File",
"confirmDelete": "Delete this document?"
},
"reminders": {
"title": "Reminders",
"subtitle": "Renewal and service reminders are added automatically from your documents and service history.",
"add": "Add reminder",
"empty": "Nothing to be reminded about yet.",
"automatic": "Automatic",
"repeats": "Repeats",
"at": "at {km}",
"doneRollForward": "Done · roll forward",
"markDone": "Mark done",
"reopen": "Reopen",
"confirmDelete": "Delete this reminder?"
},
"parts": {
"title": "Parts catalog",
"add": "Add part",
"empty": "No parts yet.",
"colPart": "Part",
"colPartNumber": "Part number",
"colNotes": "Notes",
"colFile": "File",
"confirmDelete": "Delete this part?"
},
"delete": {
"title": "Delete this car?",
"body": "This permanently deletes {name} and everything logged against it — {services}, {maintenance}, {fuel}, {documents} and {parts}. This cannot be undone.",
"services": {
"one": "{n} service record",
"other": "{n} service records"
},
"maintenance": {
"one": "{n} workshop visit",
"other": "{n} workshop visits"
},
"fuel": {
"one": "{n} refill",
"other": "{n} refills"
},
"documents": {
"one": "{n} document",
"other": "{n} documents"
},
"parts": {
"one": "{n} part",
"other": "{n} parts"
},
"typeToConfirm": "Type {name} to confirm",
"deleting": "Deleting…",
"confirm": "Delete permanently"
}
},
"attachment": {
"legend": "Attachment",
"hint": "PDF or image, up to 10MB.",
"attached": "Attached: {name}",
"willBeRemoved": "Attachment will be removed on save."
},
"forms": {
"car": {
"addTitle": "Add a car",
"editTitle": "Edit car",
"name": "Name *",
"make": "Make",
"model": "Model",
"year": "Year",
"registration": "Registration",
"registrationCountry": "Registration country",
"registrationCountryPlaceholder": "Poland",
"vin": "VIN",
"vinPlaceholder": "Vehicle Identification Number",
"fuelType": "Fuel type",
"buildDate": "Build date",
"firstRegistration": "First registration",
"oilSpec": "Engine oil spec",
"currentKm": "Current odometer (km)",
"transmissionOilSpec": "Transmission oil spec",
"differentialOilSpec": "Differential oil spec",
"brakeFluidSpec": "Brake fluid spec",
"coolantSpec": "Coolant spec",
"serviceIntervalDays": "Service interval (days)",
"serviceIntervalKm": "Service interval (km)",
"technicalCheckIntervalDays": "Technical check interval (days)",
"technicalCheckHint": "Prefills each check's next-due date. Any check can override it with the date printed on its certificate.",
"submit": "Add car"
},
"service": {
"addTitle": "Add service record",
"editTitle": "Edit service record",
"date": "Date *",
"odometer": "Odometer (km)",
"changedParts": "Changed parts",
"oil": "Oil & Oil filter",
"engineFilter": "Engine air filter",
"cabinFilter": "Cabin air filter",
"attachmentLegend": "Receipt or service-book page",
"notes": "Notes",
"autoHint": "Next service date (+{days}d) and km (+{km}) are computed automatically.",
"submit": "Add service"
},
"technical": {
"addTitle": "Add technical check",
"editTitle": "Edit technical check",
"date": "Check date *",
"result": "Result *",
"passed": "Passed",
"failed": "Failed",
"validUntil": "Valid until",
"failedHint": "A failed check certifies nothing, so no next date is derived from it.",
"derivedHint": "Leave blank to use the car's interval (+{days}d → {date}). Enter the date on the certificate when it differs.",
"cost": "Cost",
"station": "Station",
"stationPlaceholder": "Stacja Kontroli Pojazdów",
"attachmentLegend": "Inspection certificate",
"notes": "Notes",
"submit": "Add check"
},
"part": {
"addTitle": "Add part",
"editTitle": "Edit part",
"name": "Part name *",
"namePlaceholder": "Oil Filter",
"partNumber": "Part number",
"notes": "Notes",
"notesPlaceholder": "Fits 20152020 · buy in pairs",
"attachmentLegend": "Photo or spec sheet",
"submit": "Add part"
},
"fuel": {
"addTitle": "Log refill",
"editTitle": "Edit refill",
"date": "Date *",
"odometer": "Odometer (km) *",
"liters": "Litres *",
"cost": "Total cost",
"pricePerLiter": "Price per litre: {price}",
"tank": "Tank",
"fullTank": "Filled to full",
"missedFill": "I missed logging a refill before this one",
"tankHint": "Consumption is measured between full tanks, so partial fills count towards the next full one. Flagging a missed refill leaves that stretch out of the figures instead of reporting it as unrealistically economical.",
"station": "Station",
"notes": "Notes",
"attachmentLegend": "Receipt",
"submit": "Log refill"
},
"maintenance": {
"addTitle": "Log workshop visit",
"editTitle": "Edit workshop visit",
"date": "Date *",
"odometer": "Odometer (km)",
"type": "Type",
"status": "Status",
"description": "What was done *",
"descriptionPlaceholder": "Replaced alternator and drive belt",
"workshop": "Workshop",
"location": "Location",
"partsUsed": "Parts replaced",
"partsUsedPlaceholder": "Alternator 27060-0T010, belt 90916-02660",
"laborCost": "Labour cost",
"partsCost": "Parts cost",
"total": "Total: {total}",
"invoiceNumber": "Invoice number",
"warrantyUntil": "Warranty until",
"attachmentLegend": "Invoice",
"notes": "Notes",
"submit": "Log visit"
},
"document": {
"addTitle": "Add document",
"editTitle": "Edit document",
"type": "Type",
"title": "Title *",
"titlePlaceholder": "Third-party liability 2026",
"provider": "Provider",
"reference": "Policy / certificate no.",
"issued": "Issued",
"renewalDate": "Renewal date",
"renewalHint": "Leave the renewal date blank for a document that never expires. Setting it adds a reminder automatically.",
"cost": "Cost",
"attachmentLegend": "Scan or photo",
"notes": "Notes",
"submit": "Add document"
},
"reminder": {
"addTitle": "Add reminder",
"editTitle": "Edit reminder",
"title": "Title *",
"titlePlaceholder": "Swap to winter tyres",
"type": "Type",
"remindMe": "Remind me",
"onDate": "On date",
"atOdometer": "At odometer (km)",
"triggerHint": "Set either or both — with both, whichever comes first wins.",
"currentKm": "The car is at {km} now.",
"repeat": "Repeat (optional)",
"everyDays": "Every … days",
"everyKm": "Every … km",
"recurringHint": "Marking this done will roll it forward instead of closing it.",
"oneOffHint": "Leave blank for a one-off reminder that closes when you mark it done.",
"notes": "Notes",
"noTrigger": "Set a due date, a due odometer reading, or both.",
"submit": "Add reminder"
},
"share": {
"title": "Share {name}",
"body": "Give another user access to this car. Read-only lets them view; read & write also lets them edit the car and its service records and parts.",
"userEmail": "User email",
"read": "Read-only",
"write": "Read & write",
"submit": "Share",
"peopleWithAccess": "People with access",
"notShared": "Not shared with anyone yet."
}
},
"enums": {
"fuelType": {
"petrol": "Petrol (gasoline)",
"petrol_lpg": "Petrol (gasoline) + LPG",
"diesel": "Diesel",
"diesel_lpg": "Diesel + LPG",
"hybrid": "Hybrid",
"electric": "Electric",
"hydrogen": "Hydrogen"
},
"maintenanceType": {
"repair": "Repair",
"inspection": "Inspection",
"bodywork": "Bodywork",
"tyres": "Tyres",
"diagnostics": "Diagnostics",
"recall": "Recall",
"warranty": "Warranty work",
"other": "Other"
},
"maintenanceStatus": {
"scheduled": "Scheduled",
"in_progress": "In progress",
"completed": "Completed"
},
"documentType": {
"insurance": "Insurance",
"pollution": "Pollution certificate",
"registration": "Registration",
"inspection": "Inspection",
"roadTax": "Road tax",
"warranty": "Warranty",
"other": "Other"
},
"reminderTypeShort": {
"maintenance": "Maintenance",
"document": "Document",
"service": "Service",
"inspection": "Inspection",
"other": "Other"
},
"reminderType": {
"maintenance": "Maintenance",
"document": "Document renewal",
"service": "Service",
"inspection": "Inspection",
"other": "Other"
}
},
"status": {
"noData": "No data",
"serviceOverdueDays": "Service Overdue {days}d",
"dueInDays": "Due in {days}d",
"okDays": "OK · {days}d",
"noKm": "No km",
"serviceOverdueKm": "Service Overdue {km} km",
"inKm": "In {km} km",
"kmLeft": "{km} km left",
"expiredAgo": "Expired {days}d ago",
"expiresToday": "Expires today",
"renewInDays": "Renew in {days}d",
"validDays": "Valid · {days}d",
"noExpiry": "No expiry",
"done": "Done",
"noTrigger": "No trigger",
"overdue": "Overdue",
"overdueBy": "Overdue {parts}",
"dueIn": "Due in {parts}",
"upcoming": "Upcoming",
"today": "today",
"days": "{days}d",
"km": "{km} km"
}
}
Binary file not shown.
+640
View File
@@ -0,0 +1,640 @@
{
"errors": {
"sessionExpired": "Sesja wygasła — zaloguj się ponownie."
},
"common": {
"cancel": "Anuluj",
"save": "Zapisz",
"saveChanges": "Zapisz zmiany",
"saving": "Zapisywanie…",
"saved": "Zapisano ✓",
"loading": "Ładowanie…",
"edit": "Edytuj",
"remove": "Usuń",
"delete": "Usuń",
"done": "Gotowe",
"undo": "Cofnij",
"download": "Pobierz",
"empty": "—",
"yes": "Tak",
"no": "Nie"
},
"nav": {
"garage": "Garaż",
"settings": "Ustawienia",
"users": "Użytkownicy",
"lightMode": "Tryb jasny",
"darkMode": "Tryb ciemny",
"signedIn": "Zalogowano",
"logOut": "Wyloguj się"
},
"login": {
"title": "Zaloguj się",
"tagline": "Twój samochód pod kontrolą.",
"email": "E-mail",
"password": "Hasło",
"showPassword": "Pokaż hasło",
"hidePassword": "Ukryj hasło",
"submit": "Zaloguj się",
"submitting": "Logowanie…",
"failed": "Logowanie nie powiodło się",
"serverSettings": "Ustawienia serwera",
"apiServerUrl": "Adres serwera API",
"leaveBlank": "Pozostaw puste, aby użyć domyślnego ({url}).",
"resetToDefault": "Przywróć domyślny"
},
"dashboard": {
"eyebrow": "Garaż",
"title": "Twoje samochody",
"subtitle": "Przegląd serwisowy i historia napraw.",
"addCar": "Dodaj samochód",
"empty": "Nie masz jeszcze samochodów. Kliknij {action}, aby zacząć.",
"shared": "Udostępniony",
"sharedReadOnly": "Udostępniony · tylko do odczytu",
"serviceLife": "Zużycie okresu serwisowego",
"lastService": "Ostatni serwis",
"odometer": "Przebieg",
"nextDue": "Następny termin",
"nextDueKm": "Następny przebieg",
"serviceRecords": {
"one": "{n} wpis serwisowy",
"few": "{n} wpisy serwisowe",
"many": "{n} wpisów serwisowych",
"other": "{n} wpisu serwisowego"
}
},
"admin": {
"eyebrow": "Administracja",
"title": "Użytkownicy",
"subtitleAll": "Konta ze wszystkich organizacji.",
"subtitleOrg": "Konta w Twojej organizacji.",
"subtitleOrgsNote": "Organizacje przypisuje się w panelu API.",
"addUser": "Dodaj użytkownika",
"colEmail": "E-mail",
"colName": "Imię i nazwisko",
"colOrganization": "Organizacja",
"colRole": "Rola",
"colCreated": "Utworzono",
"you": "(Ty)",
"resetPassword": "Zresetuj hasło",
"confirmDelete": "Usunąć użytkownika {name}? Tej operacji nie można cofnąć.",
"cantDeleteSelf": "Nie możesz usunąć własnego konta.",
"onlySuperadminDeletes": "Tylko superadministrator może usunąć superadministratora.",
"cantChangeOwnRole": "Nie możesz zmienić własnej roli.",
"onlySuperadminEdits": "Tylko superadministrator może edytować superadministratora.",
"createTitle": "Dodaj użytkownika",
"emailRequired": "E-mail *",
"passwordRequired": "Hasło *",
"minChars": "(min. 8)",
"creating": "Tworzenie…",
"createUser": "Utwórz użytkownika",
"resetTitle": "Reset hasła — {email}",
"newPassword": "Nowe hasło",
"setPassword": "Ustaw hasło",
"roles": {
"user": "użytkownik",
"admin": "administrator",
"superadmin": "superadministrator"
}
},
"settings": {
"eyebrow": "Konto",
"title": "Ustawienia",
"subtitle": "Zarządzaj kontem, wyglądem i danymi.",
"account": {
"title": "Konto",
"name": "Imię i nazwisko",
"email": "E-mail",
"verified": "Zweryfikowany",
"notVerified": "Niezweryfikowany",
"resendVerification": "Wyślij ponownie e-mail weryfikacyjny",
"sending": "Wysyłanie…",
"verificationRequested": "Zamówiono e-mail weryfikacyjny.",
"changePassword": "Zmień hasło",
"currentPassword": "Obecne hasło",
"newPassword": "Nowe hasło",
"confirmNewPassword": "Potwierdź nowe hasło",
"mismatchYet": "Hasła jeszcze się nie zgadzają.",
"tooShort": "Nowe hasło musi mieć co najmniej 8 znaków.",
"mismatch": "Nowe hasło i potwierdzenie nie są takie same.",
"updating": "Aktualizowanie…",
"passwordUpdated": "Hasło zaktualizowane ✓",
"updatePassword": "Zaktualizuj hasło"
},
"appearance": {
"title": "Wygląd",
"theme": "Motyw",
"themeLight": "jasny",
"themeDark": "ciemny",
"themeSystem": "systemowy",
"language": "Język",
"languageHint": "Tekst aplikacji oraz nazwy miesięcy i dni.",
"languageFallbackHint": "Ten język nie jest jeszcze przetłumaczony — tekst aplikacji pozostanie po angielsku.",
"region": "Region",
"regionHint": "Format liczb i waluty.",
"dateFormat": "Format daty",
"dateExample": "Przykład: {example}",
"currency": "Waluta",
"currencyExample": "Przykład: {example} — tylko wyświetlanie, kwoty nie są przeliczane.",
"fontSize": "Rozmiar czcionki",
"fontSmall": "mała",
"fontMedium": "średnia",
"fontLarge": "duża"
},
"profile": {
"title": "Profil",
"avatarAlt": "Awatar",
"uploading": "Przesyłanie…",
"uploadPhoto": "Prześlij zdjęcie",
"bio": "O mnie",
"bioPlaceholder": "Krótka notatka widoczna dla innych osób w Twoim gospodarstwie domowym.",
"saveBio": "Zapisz opis"
},
"privacy": {
"title": "Prywatność i bezpieczeństwo",
"signOut": "Wyloguj się",
"body": "Uwierzytelnianie dwuskładnikowe nie jest jeszcze dostępne. Sesje opierają się na tokenach wydawanych przez serwer, które wygasają samoczynnie, więc wylogowanie tutaj kończy tylko sesję na tym urządzeniu — nie ma listy urządzeń do unieważnienia. Aby wylogować wszystkie urządzenia, zmień hasło powyżej."
},
"advanced": {
"title": "Zaawansowane",
"exportTitle": "Eksportuj swoje dane",
"exportBody": "Pobierz swój profil oraz wszystkie samochody, wpisy serwisowe i części w formacie JSON.",
"preparing": "Przygotowywanie…",
"exportAction": "Eksportuj dane",
"importTitle": "Importuj swoje dane",
"importBody": "Dodaj samochody z wcześniej wyeksportowanego pliku JSON. Tworzy to nowe wpisy — nic nie jest scalane ani nadpisywane.",
"importing": "Importowanie…",
"importAction": "Importuj dane",
"notJson": "Ten plik nie jest poprawnym plikiem JSON.",
"notExport": "Ten plik nie wygląda na eksport z DriverVault (brak listy \"cars\").",
"confirmImport": "Zaimportować samochody z tego pliku ({count})? Zostaną dodane nowe wpisy — istniejące samochody nie zostaną scalone ani nadpisane.",
"imported": "Zaimportowano: samochody ({cars}), wpisy serwisowe ({services}), części ({parts})."
},
"danger": {
"title": "Strefa niebezpieczna",
"body": "Usunięcie konta usuwa Twój login i profil. Nie usuwa samochodów ani historii serwisowej współdzielonych w gospodarstwie domowym. Obowiązuje 3-dniowy okres karencji, zanim usunięcie stanie się ostateczne — do tego czasu możesz je anulować.",
"deleteAccount": "Usuń moje konto",
"typeToConfirm": "Wpisz {email}, aby potwierdzić",
"requesting": "Wysyłanie żądania…",
"requestDeletion": "Zażądaj usunięcia",
"requestedOn": "Żądanie usunięcia konta złożono {date}.",
"canStillCancel": "Nadal możesz je anulować — stanie się ostateczne po 3-dniowym okresie karencji.",
"cooldownPassed": "Okres karencji minął. Możesz teraz dokończyć usuwanie.",
"cancelRequest": "Anuluj żądanie usunięcia",
"finalize": "Trwale usuń moje konto",
"confirmFinalize": "To trwale usunie Twoje konto. Tej operacji nie można cofnąć. Kontynuować?"
}
},
"car": {
"allCars": "← Wszystkie samochody",
"share": "Udostępnij",
"shared": "Udostępniony",
"sharedReadOnly": "Udostępniony · tylko do odczytu",
"tabs": {
"info": "Informacje",
"services": "Historia serwisowa",
"technical": "Historia przeglądów",
"maintenance": "Naprawy",
"fuel": "Paliwo",
"documents": "Dokumenty",
"parts": "Katalog części",
"reminders": "Przypomnienia"
},
"info": {
"oilSpec": "Specyfikacja oleju silnikowego",
"transmissionOil": "Olej przekładniowy",
"differentialOil": "Olej mostu napędowego",
"brakeFluid": "Płyn hamulcowy",
"coolant": "Płyn chłodniczy",
"odometer": "Przebieg",
"serviceInterval": "Interwał serwisowy",
"nextDue": "Następny termin",
"registrationPlate": "Numer rejestracyjny",
"registrationCountry": "Kraj rejestracji",
"vin": "VIN",
"fuelType": "Rodzaj paliwa",
"buildDate": "Data produkcji",
"firstRegistration": "Pierwsza rejestracja"
},
"services": {
"title": "Historia serwisowa",
"add": "Dodaj serwis",
"empty": "Brak wpisów serwisowych.",
"colDate": "Data",
"colKm": "Km",
"colNextDate": "Następna data",
"colNextKm": "Następny przebieg",
"colOil": "Olej i filtr oleju",
"colEngineFilter": "Filtr powietrza silnika",
"colCabinFilter": "Filtr kabinowy",
"colNotes": "Notatki",
"colFile": "Plik",
"confirmDelete": "Usunąć ten wpis serwisowy?"
},
"technical": {
"title": "Historia przeglądów technicznych",
"subtitle": "Obowiązkowe badania techniczne. Powtarzają się wyłącznie w oparciu o czas, niezależnie od przebiegu.",
"add": "Dodaj przegląd",
"empty": "Brak przeglądów technicznych.",
"colDate": "Data",
"colResult": "Wynik",
"colNextCheck": "Następny przegląd",
"colStatus": "Status",
"colStation": "Stacja",
"colCost": "Koszt",
"colNotes": "Notatki",
"colFile": "Plik",
"passed": "Pozytywny",
"failed": "Negatywny",
"confirmDelete": "Usunąć ten przegląd techniczny?"
},
"maintenance": {
"title": "Naprawy",
"subtitle": "Wizyty w warsztacie i naprawy. Rutynowa obsługa znajduje się w Historii serwisowej.",
"add": "Zapisz wizytę",
"empty": "Brak zapisanych wizyt w warsztacie.",
"colDate": "Data",
"colKm": "Km",
"colType": "Rodzaj",
"colWork": "Wykonane prace",
"colWorkshop": "Warsztat",
"colStatus": "Status",
"colCost": "Koszt",
"colFile": "Plik",
"underWarranty": "Na gwarancji · pozostało {days} dni",
"confirmDelete": "Usunąć tę wizytę w warsztacie?"
},
"fuel": {
"title": "Paliwo",
"subtitle": "Zużycie liczone jest między pełnymi bakami.",
"add": "Zapisz tankowanie",
"empty": "Brak zapisanych tankowań.",
"average": "Średnie",
"best": "Najlepsze",
"worst": "Najgorsze",
"costPerKm": "Koszt na km",
"refills": "Tankowania",
"totalLiters": "Łącznie litrów",
"totalSpent": "Łącznie wydano",
"trackedDistance": "Zmierzony dystans",
"avgPrice": "Śr. cena {price}/l",
"needTwoTanks": "Zapisz co najmniej dwa pełne baki, aby zobaczyć zużycie.",
"colDate": "Data",
"colKm": "Km",
"colLiters": "Litry",
"colCost": "Koszt",
"colPerLiter": "Za litr",
"colDistance": "Dystans",
"colConsumption": "Zużycie",
"colStation": "Stacja",
"colFile": "Plik",
"partial": "częściowe",
"gap": "luka",
"confirmDelete": "Usunąć to tankowanie?"
},
"documents": {
"title": "Dokumenty",
"subtitle": "Ubezpieczenie, zaświadczenia i inne dokumenty z terminami odnowienia.",
"add": "Dodaj dokument",
"empty": "Brak dokumentów.",
"colType": "Rodzaj",
"colTitle": "Nazwa",
"colProvider": "Wystawca",
"colIssued": "Wystawiono",
"colRenewal": "Odnowienie",
"colStatus": "Status",
"colFile": "Plik",
"confirmDelete": "Usunąć ten dokument?"
},
"reminders": {
"title": "Przypomnienia",
"subtitle": "Przypomnienia o odnowieniach i serwisach dodawane są automatycznie na podstawie dokumentów i historii serwisowej.",
"add": "Dodaj przypomnienie",
"empty": "Nie ma jeszcze o czym przypominać.",
"automatic": "Automatyczne",
"repeats": "Powtarza się",
"at": "przy {km}",
"doneRollForward": "Gotowe · przenieś dalej",
"markDone": "Oznacz jako gotowe",
"reopen": "Otwórz ponownie",
"confirmDelete": "Usunąć to przypomnienie?"
},
"parts": {
"title": "Katalog części",
"add": "Dodaj część",
"empty": "Brak części.",
"colPart": "Część",
"colPartNumber": "Numer części",
"colNotes": "Notatki",
"colFile": "Plik",
"confirmDelete": "Usunąć tę część?"
},
"delete": {
"title": "Usunąć ten samochód?",
"body": "To trwale usunie {name} i wszystko, co zostało w nim zapisane — {services}, {maintenance}, {fuel}, {documents} i {parts}. Tej operacji nie można cofnąć.",
"services": {
"one": "{n} wpis serwisowy",
"few": "{n} wpisy serwisowe",
"many": "{n} wpisów serwisowych",
"other": "{n} wpisu serwisowego"
},
"maintenance": {
"one": "{n} wizyta w warsztacie",
"few": "{n} wizyty w warsztacie",
"many": "{n} wizyt w warsztacie",
"other": "{n} wizyty w warsztacie"
},
"fuel": {
"one": "{n} tankowanie",
"few": "{n} tankowania",
"many": "{n} tankowań",
"other": "{n} tankowania"
},
"documents": {
"one": "{n} dokument",
"few": "{n} dokumenty",
"many": "{n} dokumentów",
"other": "{n} dokumentu"
},
"parts": {
"one": "{n} część",
"few": "{n} części",
"many": "{n} części",
"other": "{n} części"
},
"typeToConfirm": "Wpisz {name}, aby potwierdzić",
"deleting": "Usuwanie…",
"confirm": "Usuń trwale"
}
},
"attachment": {
"legend": "Załącznik",
"hint": "PDF lub obraz, do 10 MB.",
"attached": "Załączono: {name}",
"willBeRemoved": "Załącznik zostanie usunięty przy zapisie."
},
"forms": {
"car": {
"addTitle": "Dodaj samochód",
"editTitle": "Edytuj samochód",
"name": "Nazwa *",
"make": "Marka",
"model": "Model",
"year": "Rok",
"registration": "Numer rejestracyjny",
"registrationCountry": "Kraj rejestracji",
"registrationCountryPlaceholder": "Polska",
"vin": "VIN",
"vinPlaceholder": "Numer identyfikacyjny pojazdu",
"fuelType": "Rodzaj paliwa",
"buildDate": "Data produkcji",
"firstRegistration": "Pierwsza rejestracja",
"oilSpec": "Specyfikacja oleju silnikowego",
"currentKm": "Aktualny przebieg (km)",
"transmissionOilSpec": "Specyfikacja oleju przekładniowego",
"differentialOilSpec": "Specyfikacja oleju mostu napędowego",
"brakeFluidSpec": "Specyfikacja płynu hamulcowego",
"coolantSpec": "Specyfikacja płynu chłodniczego",
"serviceIntervalDays": "Interwał serwisowy (dni)",
"serviceIntervalKm": "Interwał serwisowy (km)",
"technicalCheckIntervalDays": "Interwał przeglądu technicznego (dni)",
"technicalCheckHint": "Wstępnie wypełnia termin następnego przeglądu. Każdy przegląd może go nadpisać datą z zaświadczenia.",
"submit": "Dodaj samochód"
},
"service": {
"addTitle": "Dodaj wpis serwisowy",
"editTitle": "Edytuj wpis serwisowy",
"date": "Data *",
"odometer": "Przebieg (km)",
"changedParts": "Wymienione części",
"oil": "Olej i filtr oleju",
"engineFilter": "Filtr powietrza silnika",
"cabinFilter": "Filtr kabinowy",
"attachmentLegend": "Paragon lub strona książki serwisowej",
"notes": "Notatki",
"autoHint": "Data (+{days} dni) i przebieg (+{km}) następnego serwisu są obliczane automatycznie.",
"submit": "Dodaj serwis"
},
"technical": {
"addTitle": "Dodaj przegląd techniczny",
"editTitle": "Edytuj przegląd techniczny",
"date": "Data przeglądu *",
"result": "Wynik *",
"passed": "Pozytywny",
"failed": "Negatywny",
"validUntil": "Ważny do",
"failedHint": "Negatywny przegląd niczego nie potwierdza, więc nie wyznacza następnego terminu.",
"derivedHint": "Pozostaw puste, aby użyć interwału samochodu (+{days} dni → {date}). Wpisz datę z zaświadczenia, jeśli jest inna.",
"cost": "Koszt",
"station": "Stacja",
"stationPlaceholder": "Stacja Kontroli Pojazdów",
"attachmentLegend": "Zaświadczenie o przeglądzie",
"notes": "Notatki",
"submit": "Dodaj przegląd"
},
"part": {
"addTitle": "Dodaj część",
"editTitle": "Edytuj część",
"name": "Nazwa części *",
"namePlaceholder": "Filtr oleju",
"partNumber": "Numer części",
"notes": "Notatki",
"notesPlaceholder": "Pasuje do 20152020 · kupować parami",
"attachmentLegend": "Zdjęcie lub karta katalogowa",
"submit": "Dodaj część"
},
"fuel": {
"addTitle": "Zapisz tankowanie",
"editTitle": "Edytuj tankowanie",
"date": "Data *",
"odometer": "Przebieg (km) *",
"liters": "Litry *",
"cost": "Koszt całkowity",
"pricePerLiter": "Cena za litr: {price}",
"tank": "Bak",
"fullTank": "Zatankowano do pełna",
"missedFill": "Nie zapisałem tankowania przed tym",
"tankHint": "Zużycie liczone jest między pełnymi bakami, więc tankowania częściowe wliczają się do następnego pełnego. Oznaczenie pominiętego tankowania wyklucza ten odcinek z obliczeń, zamiast pokazywać nierealnie niskie spalanie.",
"station": "Stacja",
"notes": "Notatki",
"attachmentLegend": "Paragon",
"submit": "Zapisz tankowanie"
},
"maintenance": {
"addTitle": "Zapisz wizytę w warsztacie",
"editTitle": "Edytuj wizytę w warsztacie",
"date": "Data *",
"odometer": "Przebieg (km)",
"type": "Rodzaj",
"status": "Status",
"description": "Co zostało zrobione *",
"descriptionPlaceholder": "Wymiana alternatora i paska napędowego",
"workshop": "Warsztat",
"location": "Lokalizacja",
"partsUsed": "Wymienione części",
"partsUsedPlaceholder": "Alternator 27060-0T010, pasek 90916-02660",
"laborCost": "Koszt robocizny",
"partsCost": "Koszt części",
"total": "Razem: {total}",
"invoiceNumber": "Numer faktury",
"warrantyUntil": "Gwarancja do",
"attachmentLegend": "Faktura",
"notes": "Notatki",
"submit": "Zapisz wizytę"
},
"document": {
"addTitle": "Dodaj dokument",
"editTitle": "Edytuj dokument",
"type": "Rodzaj",
"title": "Nazwa *",
"titlePlaceholder": "OC 2026",
"provider": "Wystawca",
"reference": "Nr polisy / zaświadczenia",
"issued": "Wystawiono",
"renewalDate": "Data odnowienia",
"renewalHint": "Pozostaw datę odnowienia pustą dla dokumentu bezterminowego. Ustawienie jej automatycznie doda przypomnienie.",
"cost": "Koszt",
"attachmentLegend": "Skan lub zdjęcie",
"notes": "Notatki",
"submit": "Dodaj dokument"
},
"reminder": {
"addTitle": "Dodaj przypomnienie",
"editTitle": "Edytuj przypomnienie",
"title": "Nazwa *",
"titlePlaceholder": "Zmiana na opony zimowe",
"type": "Rodzaj",
"remindMe": "Przypomnij mi",
"onDate": "W dniu",
"atOdometer": "Przy przebiegu (km)",
"triggerHint": "Ustaw jedno lub oba — przy obu liczy się to, co nastąpi wcześniej.",
"currentKm": "Samochód ma teraz {km}.",
"repeat": "Powtarzanie (opcjonalnie)",
"everyDays": "Co … dni",
"everyKm": "Co … km",
"recurringHint": "Oznaczenie jako gotowe przeniesie je dalej, zamiast zamknąć.",
"oneOffHint": "Pozostaw puste dla jednorazowego przypomnienia, które zamknie się po oznaczeniu jako gotowe.",
"notes": "Notatki",
"noTrigger": "Ustaw datę, przebieg lub oba.",
"submit": "Dodaj przypomnienie"
},
"share": {
"title": "Udostępnij {name}",
"body": "Daj innemu użytkownikowi dostęp do tego samochodu. Tylko do odczytu pozwala na podgląd; odczyt i zapis pozwala też edytować samochód oraz jego wpisy serwisowe i części.",
"userEmail": "E-mail użytkownika",
"read": "Tylko do odczytu",
"write": "Odczyt i zapis",
"submit": "Udostępnij",
"peopleWithAccess": "Osoby z dostępem",
"notShared": "Jeszcze nikomu nie udostępniono."
}
},
"enums": {
"fuelType": {
"petrol": "Benzyna",
"petrol_lpg": "Benzyna + LPG",
"diesel": "Diesel",
"diesel_lpg": "Diesel + LPG",
"hybrid": "Hybryda",
"electric": "Elektryczny",
"hydrogen": "Wodór"
},
"maintenanceType": {
"repair": "Naprawa",
"inspection": "Przegląd",
"bodywork": "Blacharka",
"tyres": "Opony",
"diagnostics": "Diagnostyka",
"recall": "Akcja serwisowa",
"warranty": "Naprawa gwarancyjna",
"other": "Inne"
},
"maintenanceStatus": {
"scheduled": "Zaplanowana",
"in_progress": "W trakcie",
"completed": "Zakończona"
},
"documentType": {
"insurance": "Ubezpieczenie",
"pollution": "Zaświadczenie o emisji spalin",
"registration": "Dowód rejestracyjny",
"inspection": "Przegląd",
"roadTax": "Podatek drogowy",
"warranty": "Gwarancja",
"other": "Inne"
},
"reminderTypeShort": {
"maintenance": "Naprawa",
"document": "Dokument",
"service": "Serwis",
"inspection": "Przegląd",
"other": "Inne"
},
"reminderType": {
"maintenance": "Naprawa",
"document": "Odnowienie dokumentu",
"service": "Serwis",
"inspection": "Przegląd",
"other": "Inne"
}
},
"status": {
"noData": "Brak danych",
"serviceOverdueDays": "Serwis zaległy {days} dni",
"dueInDays": "Termin za {days} dni",
"okDays": "OK · {days} dni",
"noKm": "Brak przebiegu",
"serviceOverdueKm": "Serwis zaległy {km} km",
"inKm": "Za {km} km",
"kmLeft": "Pozostało {km} km",
"expiredAgo": "Wygasło {days} dni temu",
"expiresToday": "Wygasa dzisiaj",
"renewInDays": "Odnowienie za {days} dni",
"validDays": "Ważne · {days} dni",
"noExpiry": "Bezterminowe",
"done": "Gotowe",
"noTrigger": "Brak wyzwalacza",
"overdue": "Zaległe",
"overdueBy": "Zaległe {parts}",
"dueIn": "Termin za {parts}",
"upcoming": "Nadchodzące",
"today": "dzisiaj",
"days": "{days} dni",
"km": "{km} km"
}
}
+21 -20
View File
@@ -4,6 +4,7 @@
// odometer approaches/passes the computed next-service km.
import { prefs } from "../prefs.js";
import { t } from "../i18n/index.js";
export function formatDate(value) {
if (!value) return "—";
@@ -69,19 +70,19 @@ const STYLE = {
// dateSignal classifies the next-due date relative to today.
function dateSignal(nextServiceDate) {
const days = daysUntil(nextServiceDate);
if (days == null) return { key: "unknown", label: "No data" };
if (days < 0) return { key: "overdue", label: `Service Overdue ${Math.abs(days)}d` };
if (days <= 30) return { key: "soon", label: `Due in ${days}d` };
return { key: "ok", label: `OK · ${days}d` };
if (days == null) return { key: "unknown", label: t("status.noData") };
if (days < 0) return { key: "overdue", label: t("status.serviceOverdueDays", { days: Math.abs(days) }) };
if (days <= 30) return { key: "soon", label: t("status.dueInDays", { days }) };
return { key: "ok", label: t("status.okDays", { days }) };
}
// kmSignal classifies the current odometer against the next-due km.
function kmSignal(currentKm, nextServiceKm) {
if (!currentKm || !nextServiceKm) return { key: "unknown", label: "No km" };
if (!currentKm || !nextServiceKm) return { key: "unknown", label: t("status.noKm") };
const remaining = nextServiceKm - currentKm;
if (remaining < 0) return { key: "overdue", label: `Service Overdue ${num(Math.abs(remaining))} km` };
if (remaining <= KM_SOON) return { key: "soon", label: `In ${num(remaining)} km` };
return { key: "ok", label: `${num(remaining)} km left` };
if (remaining < 0) return { key: "overdue", label: t("status.serviceOverdueKm", { km: num(Math.abs(remaining)) }) };
if (remaining <= KM_SOON) return { key: "soon", label: t("status.inKm", { km: num(remaining) }) };
return { key: "ok", label: t("status.kmLeft", { km: num(remaining) }) };
}
// serviceStatus combines the date- and km-based signals, returning the worse of
@@ -137,16 +138,16 @@ export function expiryStatus(doc) {
let label;
switch (state) {
case "expired":
label = `Expired ${Math.abs(days)}d ago`;
label = t("status.expiredAgo", { days: Math.abs(days) });
break;
case "expiring_soon":
label = days === 0 ? "Expires today" : `Renew in ${days}d`;
label = days === 0 ? t("status.expiresToday") : t("status.renewInDays", { days });
break;
case "valid":
label = `Valid · ${days}d`;
label = t("status.validDays", { days });
break;
default:
label = "No expiry";
label = t("status.noExpiry");
}
return { key: state, label, classes: EXPIRY_STYLE[state] || EXPIRY_STYLE.no_expiry };
}
@@ -168,19 +169,19 @@ export function reminderStatus(rem) {
const km = rem?.kmLeft;
let label;
if (state === "done") label = "Done";
else if (state === "no_trigger") label = "No trigger";
if (state === "done") label = t("status.done");
else if (state === "no_trigger") label = t("status.noTrigger");
else if (state === "overdue") {
const parts = [];
if (days != null && days < 0) parts.push(`${Math.abs(days)}d`);
if (km != null && km < 0) parts.push(`${num(Math.abs(km))} km`);
label = parts.length ? `Overdue ${parts.join(" · ")}` : "Overdue";
if (days != null && days < 0) parts.push(t("status.days", { days: Math.abs(days) }));
if (km != null && km < 0) parts.push(t("status.km", { km: num(Math.abs(km)) }));
label = parts.length ? t("status.overdueBy", { parts: parts.join(" · ") }) : t("status.overdue");
} else {
// Lead with the trigger that is closest to firing.
const parts = [];
if (days != null && days >= 0) parts.push(days === 0 ? "today" : `${days}d`);
if (km != null && km >= 0) parts.push(`${num(km)} km`);
label = parts.length ? `Due in ${parts.join(" · ")}` : "Upcoming";
if (days != null && days >= 0) parts.push(days === 0 ? t("status.today") : t("status.days", { days }));
if (km != null && km >= 0) parts.push(t("status.km", { km: num(km) }));
label = parts.length ? t("status.dueIn", { parts: parts.join(" · ") }) : t("status.upcoming");
}
return { key: state, label, classes: REMINDER_STYLE[state] || REMINDER_STYLE.no_trigger };
}
+36 -35
View File
@@ -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
View File
@@ -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 &amp; 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>
+15 -13
View File
@@ -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>
+15 -13
View File
@@ -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>
+80 -87
View File
@@ -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 &amp; 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>