Add technical check history

A car's mandatory roadworthiness inspections — przegląd techniczny, MOT,
TÜV — had nowhere to live. Log them behind Service history, which this
mirrors minus the odometer: an inspection falls due on a date whatever
the mileage reads.

The cycle is legal rather than mechanical, and it is not constant — a new
car's first check falls due years later than its third. So the car's
technical_check_interval_days only prefills the next date, and any check
overrides it with the valid-until printed on its own certificate. A
failed check derives no next date at all: reading one off a failure would
stamp a reassuring "valid until" on a car that just flunked.

The next-due date and its status are derived on every read rather than
stored, for the same reason documents are — a stored verdict goes stale
as the date passes. The response carries the same expiry shape documents
use, so the web app reuses the existing badge rather than growing a
second one.

Each check records result, cost, station and notes, and holds the
certificate as its single attachment on the same terms as every other
record.

Needs `node scripts/setup-pocketbase.mjs` to create technical_checks and
add the interval to cars. The reformatting in records.go is gofmt
realigning the car struct around a longer field name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 4.8 committed 2026-07-17 11:54:26 +02:00
1 parent 03738f08dc
commit 21c99ad762
11 files changed
+713 -81

No files matched your search

+11
View File
@@ -135,6 +135,16 @@ export const api = {
request(`/service-records/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteService: (id) => request(`/service-records/${id}`, { method: "DELETE" }),
// Technical checks — roadworthiness inspections. nextCheckDate and the expiry
// assessment are derived server-side from the certificate's valid-until date,
// falling back to the car's interval.
listCarTechnicalChecks: (carId) => request(`/cars/${carId}/technical-checks`),
createTechnicalCheck: (body) =>
request("/technical-checks", { method: "POST", body: JSON.stringify(body) }),
updateTechnicalCheck: (id, body) =>
request(`/technical-checks/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteTechnicalCheck: (id) => request(`/technical-checks/${id}`, { method: "DELETE" }),
// Parts
listCarParts: (carId) => request(`/cars/${carId}/parts`),
createPart: (body) => request("/parts", { method: "POST", body: JSON.stringify(body) }),
@@ -170,6 +180,7 @@ export const api = {
files: {
documents: attachment("/car-documents"),
services: attachment("/service-records"),
technical: attachment("/technical-checks"),
maintenance: attachment("/maintenance"),
fuel: attachment("/fuel-entries"),
parts: attachment("/parts"),
@@ -27,6 +27,7 @@ const form = ref({
coolantSpec: props.car?.coolantSpec ?? "",
serviceIntervalDays: props.car?.serviceIntervalDays || 365,
serviceIntervalKm: props.car?.serviceIntervalKm || 15000,
technicalCheckIntervalDays: props.car?.technicalCheckIntervalDays || 365,
currentKm: props.car?.currentKm || "",
});
@@ -52,6 +53,7 @@ async function submit() {
coolantSpec: form.value.coolantSpec.trim(),
serviceIntervalDays: Number(form.value.serviceIntervalDays) || 365,
serviceIntervalKm: Number(form.value.serviceIntervalKm) || 15000,
technicalCheckIntervalDays: Number(form.value.technicalCheckIntervalDays) || 365,
currentKm: form.value.currentKm ? Number(form.value.currentKm) : 0,
};
const saved = isEdit
@@ -162,6 +164,14 @@ async function submit() {
<input v-model="form.serviceIntervalKm" type="number" class="dh-input data" />
</div>
</div>
<div>
<label class="dh-label">Technical check interval (days)</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>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
@@ -0,0 +1,139 @@
<script setup>
import { ref, computed } from "vue";
import { api } from "../api";
import { formatDate } from "../lib/format.js";
import { applyAttachment } from "../lib/attachment.js";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
const props = defineProps({
carId: { type: String, required: true },
car: { type: Object, default: null },
check: { type: Object, default: null },
});
const emit = defineEmits(["saved", "close"]);
const isEdit = !!props.check;
const saving = ref(false);
const error = ref("");
const form = ref({
date: props.check ? toDateInput(props.check.date) : new Date().toISOString().slice(0, 10),
result: props.check?.result ?? "passed",
validUntil: props.check?.validUntil ? toDateInput(props.check.validUntil) : "",
cost: props.check?.cost || "",
station: props.check?.station ?? "",
notes: props.check?.notes ?? "",
});
const file = ref(null);
const removeFile = ref(false);
function toDateInput(value) {
const d = new Date(value);
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
}
// What the server will derive if valid-until is left blank, shown so the effect
// of leaving it empty is visible before saving rather than after.
const derivedNext = computed(() => {
if (form.value.result === "failed" || !form.value.date) return null;
const days = props.car?.technicalCheckIntervalDays || 365;
const d = new Date(form.value.date);
if (isNaN(d)) return null;
d.setDate(d.getDate() + days);
return { days, date: formatDate(d.toISOString()) };
});
async function submit() {
saving.value = true;
error.value = "";
try {
const payload = {
car: props.carId,
date: new Date(form.value.date).toISOString(),
result: form.value.result,
// Blank means "derive it from the car's interval" — send null, not a date,
// so clearing the field on an edit actually removes the override.
validUntil: form.value.validUntil ? new Date(form.value.validUntil).toISOString() : null,
cost: form.value.cost ? Number(form.value.cost) : 0,
station: form.value.station.trim(),
notes: form.value.notes.trim(),
};
const saved = isEdit
? await api.updateTechnicalCheck(props.check.id, payload)
: await api.createTechnicalCheck(payload);
emit("saved", await applyAttachment(api.files.technical, saved, {
file: file.value,
remove: removeFile.value,
}));
} catch (e) {
error.value = e.message;
} finally {
saving.value = false;
}
}
</script>
<template>
<Modal :title="isEdit ? 'Edit technical check' : 'Add technical check'" @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>
<input v-model="form.date" type="date" required class="dh-input data" />
</div>
<div>
<label class="dh-label">Result *</label>
<select v-model="form.result" class="dh-input">
<option value="passed">Passed</option>
<option value="failed">Failed</option>
</select>
</div>
</div>
<div>
<label class="dh-label">Valid until</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.
</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.
</p>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">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" />
</div>
</div>
<AttachmentField
v-model:file="file"
v-model:remove="removeFile"
:record="check"
legend="Inspection certificate"
/>
<div>
<label class="dh-label">Notes</label>
<input v-model="form.notes" class="dh-input" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving…" : isEdit ? "Save changes" : "Add check" }}
</button>
</div>
</form>
</Modal>
</template>
+104
View File
@@ -15,6 +15,7 @@ import {
} from "../lib/format.js";
import CarFormModal from "../components/CarFormModal.vue";
import ServiceFormModal from "../components/ServiceFormModal.vue";
import TechnicalCheckFormModal from "../components/TechnicalCheckFormModal.vue";
import PartFormModal from "../components/PartFormModal.vue";
import FuelFormModal from "../components/FuelFormModal.vue";
import MaintenanceFormModal from "../components/MaintenanceFormModal.vue";
@@ -27,6 +28,7 @@ const router = useRouter();
const car = ref(null);
const services = ref([]);
const technicalChecks = ref([]);
const parts = ref([]);
const fuel = ref([]);
const fuelStats = ref(null);
@@ -40,6 +42,8 @@ const error = ref("");
const showCarEdit = ref(false);
const showService = ref(false);
const editingService = ref(null);
const showTechnicalCheck = ref(false);
const editingTechnicalCheck = ref(null);
const showPart = ref(false);
const editingPart = ref(null);
const showFuel = ref(false);
@@ -80,6 +84,7 @@ const dueReminders = computed(
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" },
@@ -94,6 +99,7 @@ async function load() {
[
car.value,
services.value,
technicalChecks.value,
parts.value,
fuel.value,
fuelStats.value,
@@ -103,6 +109,7 @@ async function load() {
] = await Promise.all([
api.getCar(props.id),
api.listCarServices(props.id),
api.listCarTechnicalChecks(props.id),
api.listCarParts(props.id),
api.listCarFuel(props.id),
api.getCarFuelStats(props.id),
@@ -141,6 +148,30 @@ async function deleteService(id) {
}
}
// --- technical checks ---
function openAddTechnicalCheck() {
editingTechnicalCheck.value = null;
showTechnicalCheck.value = true;
}
function openEditTechnicalCheck(t) {
editingTechnicalCheck.value = t;
showTechnicalCheck.value = true;
}
async function onTechnicalCheckSaved() {
showTechnicalCheck.value = false;
editingTechnicalCheck.value = null;
await load();
}
async function deleteTechnicalCheck(id) {
if (!confirm("Delete this technical check?")) return;
try {
await api.deleteTechnicalCheck(id);
await load();
} catch (e) {
error.value = e.message;
}
}
function openAddPart() {
editingPart.value = null;
showPart.value = true;
@@ -538,6 +569,71 @@ onMounted(load);
</div>
</section>
<!-- Technical check history -->
<section v-else-if="activeTab === 'technical'">
<div class="mb-3 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Technical check history</h2>
<p class="text-sm text-muted">
Mandatory roadworthiness inspections. Recurs on time alone, whatever the odometer reads.
</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
</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.
</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 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>
<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>
</td>
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ formatDate(t.nextCheckDate) }}</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="expiryStatus(t).classes">{{ expiryStatus(t).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="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>
<span v-else class="text-xs text-muted">—</span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditTechnicalCheck(t)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteTechnicalCheck(t.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Maintenance -->
<section v-else-if="activeTab === 'maintenance'">
<div class="mb-3 flex items-center justify-between">
@@ -892,6 +988,14 @@ onMounted(load);
@saved="onServiceSaved"
@close="showService = false"
/>
<TechnicalCheckFormModal
v-if="showTechnicalCheck"
:car-id="id"
:car="car"
:check="editingTechnicalCheck"
@saved="onTechnicalCheckSaved"
@close="showTechnicalCheck = false"
/>
<PartFormModal
v-if="showPart"
:car-id="id"