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:
co-authored by
Claude Opus 4.8
parent
03738f08dc
commit
21c99ad762
@@ -85,8 +85,9 @@ other users `read` or `write` access. Every car/service/part handler is gated by
|
||||
|
||||
| Collection | Purpose | Key fields |
|
||||
|---|---|---|
|
||||
| `cars` | one per car | name, make, model, year, registration, vin, `currentKm`, `serviceIntervalDays` (365), `serviceIntervalKm` (15000), `oilSpec`, `transmissionOilSpec`, `differentialOilSpec`, `brakeFluidSpec`, `coolantSpec`, `owner` |
|
||||
| `cars` | one per car | name, make, model, year, registration, vin, `currentKm`, `serviceIntervalDays` (365), `serviceIntervalKm` (15000), `technicalCheckIntervalDays` (365), `oilSpec`, `transmissionOilSpec`, `differentialOilSpec`, `brakeFluidSpec`, `coolantSpec`, `owner` |
|
||||
| `service_records` | the service log | car, date, km, changed_oil, changed_engine_air_filter, changed_cabin_air_filter, notes |
|
||||
| `technical_checks` | roadworthiness inspections (przegląd techniczny / MOT / TÜV) | car, date, `result` (passed \| failed), cost, station, `valid_until`, notes |
|
||||
| `parts` | per-car parts catalog | car, name, part_number, category |
|
||||
| `car_shares` | grants another user access to a car | car, user, `permission` (read \| write) |
|
||||
| `organizations` | tenants | name (unique) |
|
||||
|
||||
@@ -232,4 +232,7 @@ func applyCarDefaults(c *models.Car) {
|
||||
if c.ServiceIntervalKm <= 0 {
|
||||
c.ServiceIntervalKm = 15000
|
||||
}
|
||||
if c.TechnicalCheckIntervalDays <= 0 {
|
||||
c.TechnicalCheckIntervalDays = models.DefaultTechnicalCheckIntervalDays
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,78 +41,81 @@ func formatPBDate(t time.Time) string {
|
||||
|
||||
// carRecord is the PocketBase-facing shape of a car (snake_case fields).
|
||||
type carRecord struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Make string `json:"make"`
|
||||
Model string `json:"model"`
|
||||
Year int `json:"year"`
|
||||
Registration string `json:"registration"`
|
||||
RegistrationCountry string `json:"registration_country"`
|
||||
VIN string `json:"vin"`
|
||||
ServiceIntervalDays int `json:"service_interval_days"`
|
||||
ServiceIntervalKm int `json:"service_interval_km"`
|
||||
OilSpec string `json:"oil_spec"`
|
||||
TransmissionOilSpec string `json:"transmission_oil_spec"`
|
||||
DifferentialOilSpec string `json:"differential_oil_spec"`
|
||||
BrakeFluidSpec string `json:"brake_fluid_spec"`
|
||||
CoolantSpec string `json:"coolant_spec"`
|
||||
CurrentKm int `json:"current_km"`
|
||||
FuelType string `json:"fuel_type"`
|
||||
BuildDate string `json:"build_date"`
|
||||
FirstRegistrationDate string `json:"first_registration_date"`
|
||||
Owner string `json:"owner"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Make string `json:"make"`
|
||||
Model string `json:"model"`
|
||||
Year int `json:"year"`
|
||||
Registration string `json:"registration"`
|
||||
RegistrationCountry string `json:"registration_country"`
|
||||
VIN string `json:"vin"`
|
||||
ServiceIntervalDays int `json:"service_interval_days"`
|
||||
ServiceIntervalKm int `json:"service_interval_km"`
|
||||
TechnicalCheckIntervalDays int `json:"technical_check_interval_days"`
|
||||
OilSpec string `json:"oil_spec"`
|
||||
TransmissionOilSpec string `json:"transmission_oil_spec"`
|
||||
DifferentialOilSpec string `json:"differential_oil_spec"`
|
||||
BrakeFluidSpec string `json:"brake_fluid_spec"`
|
||||
CoolantSpec string `json:"coolant_spec"`
|
||||
CurrentKm int `json:"current_km"`
|
||||
FuelType string `json:"fuel_type"`
|
||||
BuildDate string `json:"build_date"`
|
||||
FirstRegistrationDate string `json:"first_registration_date"`
|
||||
Owner string `json:"owner"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func (rec carRecord) toModel() models.Car {
|
||||
return models.Car{
|
||||
ID: rec.ID,
|
||||
Name: rec.Name,
|
||||
Make: rec.Make,
|
||||
Model: rec.Model,
|
||||
Year: rec.Year,
|
||||
Registration: rec.Registration,
|
||||
RegistrationCountry: rec.RegistrationCountry,
|
||||
VIN: rec.VIN,
|
||||
ServiceIntervalDays: rec.ServiceIntervalDays,
|
||||
ServiceIntervalKm: rec.ServiceIntervalKm,
|
||||
OilSpec: rec.OilSpec,
|
||||
TransmissionOilSpec: rec.TransmissionOilSpec,
|
||||
DifferentialOilSpec: rec.DifferentialOilSpec,
|
||||
BrakeFluidSpec: rec.BrakeFluidSpec,
|
||||
CoolantSpec: rec.CoolantSpec,
|
||||
CurrentKm: rec.CurrentKm,
|
||||
FuelType: rec.FuelType,
|
||||
BuildDate: rec.BuildDate,
|
||||
FirstRegistrationDate: rec.FirstRegistrationDate,
|
||||
Owner: rec.Owner,
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
ID: rec.ID,
|
||||
Name: rec.Name,
|
||||
Make: rec.Make,
|
||||
Model: rec.Model,
|
||||
Year: rec.Year,
|
||||
Registration: rec.Registration,
|
||||
RegistrationCountry: rec.RegistrationCountry,
|
||||
VIN: rec.VIN,
|
||||
ServiceIntervalDays: rec.ServiceIntervalDays,
|
||||
ServiceIntervalKm: rec.ServiceIntervalKm,
|
||||
TechnicalCheckIntervalDays: rec.TechnicalCheckIntervalDays,
|
||||
OilSpec: rec.OilSpec,
|
||||
TransmissionOilSpec: rec.TransmissionOilSpec,
|
||||
DifferentialOilSpec: rec.DifferentialOilSpec,
|
||||
BrakeFluidSpec: rec.BrakeFluidSpec,
|
||||
CoolantSpec: rec.CoolantSpec,
|
||||
CurrentKm: rec.CurrentKm,
|
||||
FuelType: rec.FuelType,
|
||||
BuildDate: rec.BuildDate,
|
||||
FirstRegistrationDate: rec.FirstRegistrationDate,
|
||||
Owner: rec.Owner,
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
// carPayload builds the write payload for create/update from a domain Car.
|
||||
func carPayload(c models.Car) map[string]any {
|
||||
return map[string]any{
|
||||
"name": c.Name,
|
||||
"make": c.Make,
|
||||
"model": c.Model,
|
||||
"year": c.Year,
|
||||
"registration": c.Registration,
|
||||
"registration_country": c.RegistrationCountry,
|
||||
"vin": c.VIN,
|
||||
"service_interval_days": c.ServiceIntervalDays,
|
||||
"service_interval_km": c.ServiceIntervalKm,
|
||||
"oil_spec": c.OilSpec,
|
||||
"transmission_oil_spec": c.TransmissionOilSpec,
|
||||
"differential_oil_spec": c.DifferentialOilSpec,
|
||||
"brake_fluid_spec": c.BrakeFluidSpec,
|
||||
"coolant_spec": c.CoolantSpec,
|
||||
"current_km": c.CurrentKm,
|
||||
"fuel_type": c.FuelType,
|
||||
"build_date": c.BuildDate,
|
||||
"first_registration_date": c.FirstRegistrationDate,
|
||||
"name": c.Name,
|
||||
"make": c.Make,
|
||||
"model": c.Model,
|
||||
"year": c.Year,
|
||||
"registration": c.Registration,
|
||||
"registration_country": c.RegistrationCountry,
|
||||
"vin": c.VIN,
|
||||
"service_interval_days": c.ServiceIntervalDays,
|
||||
"service_interval_km": c.ServiceIntervalKm,
|
||||
"technical_check_interval_days": c.TechnicalCheckIntervalDays,
|
||||
"oil_spec": c.OilSpec,
|
||||
"transmission_oil_spec": c.TransmissionOilSpec,
|
||||
"differential_oil_spec": c.DifferentialOilSpec,
|
||||
"brake_fluid_spec": c.BrakeFluidSpec,
|
||||
"coolant_spec": c.CoolantSpec,
|
||||
"current_km": c.CurrentKm,
|
||||
"fuel_type": c.FuelType,
|
||||
"build_date": c.BuildDate,
|
||||
"first_registration_date": c.FirstRegistrationDate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +164,51 @@ func servicePayload(r models.ServiceRecord) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// --- technical checks ---
|
||||
|
||||
type technicalCheckRecord struct {
|
||||
ID string `json:"id"`
|
||||
Car string `json:"car"`
|
||||
Date string `json:"date"`
|
||||
Result string `json:"result"`
|
||||
Cost float64 `json:"cost"`
|
||||
Station string `json:"station"`
|
||||
ValidUntil string `json:"valid_until"`
|
||||
Notes string `json:"notes"`
|
||||
File string `json:"file"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func (rec technicalCheckRecord) toModel() models.TechnicalCheck {
|
||||
return models.TechnicalCheck{
|
||||
ID: rec.ID,
|
||||
Car: rec.Car,
|
||||
Date: parsePBDate(rec.Date),
|
||||
Result: rec.Result,
|
||||
Cost: rec.Cost,
|
||||
Station: rec.Station,
|
||||
ValidUntil: parsePBDatePtr(rec.ValidUntil),
|
||||
Notes: rec.Notes,
|
||||
Attachment: attachmentOf(rec.File),
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
// technicalCheckPayload omits the file field — see attachmentOf.
|
||||
func technicalCheckPayload(t models.TechnicalCheck) map[string]any {
|
||||
return map[string]any{
|
||||
"car": t.Car,
|
||||
"date": formatPBDate(t.Date),
|
||||
"result": t.Result,
|
||||
"cost": t.Cost,
|
||||
"station": t.Station,
|
||||
"valid_until": formatPBDatePtr(t.ValidUntil),
|
||||
"notes": t.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
// --- attachments ---
|
||||
|
||||
// attachmentOf renders a PocketBase file field into the model's attachment pair.
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
// GET /api/parts POST /api/parts
|
||||
// GET /api/parts/{id} PATCH /api/parts/{id} DELETE /api/parts/{id}
|
||||
//
|
||||
// # technical checks (roadworthiness inspections; time-only, next date derived)
|
||||
// GET /api/cars/{id}/technical-checks
|
||||
// GET /api/technical-checks POST /api/technical-checks
|
||||
// GET /api/technical-checks/{id} PATCH /api/technical-checks/{id}
|
||||
// DELETE /api/technical-checks/{id}
|
||||
//
|
||||
// # fuel tracking (efficiency is derived on read, never stored)
|
||||
// GET /api/cars/{id}/fuel-entries
|
||||
// GET /api/cars/{id}/fuel-stats
|
||||
@@ -74,7 +80,8 @@
|
||||
// DELETE /api/car-documents/{id}
|
||||
//
|
||||
// # attachments — one optional file per record, same three verbs everywhere.
|
||||
// # {records} is car-documents | service-records | maintenance | fuel-entries | parts
|
||||
// # {records} is car-documents | service-records | technical-checks | maintenance
|
||||
// # | fuel-entries | parts
|
||||
// POST /api/{records}/{id}/file
|
||||
// GET /api/{records}/{id}/file
|
||||
// DELETE /api/{records}/{id}/file
|
||||
@@ -101,15 +108,16 @@ import (
|
||||
|
||||
// PocketBase collection names.
|
||||
const (
|
||||
colCars = "cars"
|
||||
colServices = "service_records"
|
||||
colParts = "parts"
|
||||
colShares = "car_shares"
|
||||
colOrgs = "organizations"
|
||||
colFuel = "fuel_entries"
|
||||
colMaintenance = "maintenance_entries"
|
||||
colDocuments = "car_documents"
|
||||
colReminders = "reminders"
|
||||
colCars = "cars"
|
||||
colServices = "service_records"
|
||||
colTechnicalChecks = "technical_checks"
|
||||
colParts = "parts"
|
||||
colShares = "car_shares"
|
||||
colOrgs = "organizations"
|
||||
colFuel = "fuel_entries"
|
||||
colMaintenance = "maintenance_entries"
|
||||
colDocuments = "car_documents"
|
||||
colReminders = "reminders"
|
||||
)
|
||||
|
||||
// Server wires together the HTTP handlers and their dependencies.
|
||||
@@ -241,6 +249,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("PATCH /api/cars/{id}", s.updateCar)
|
||||
mux.HandleFunc("DELETE /api/cars/{id}", s.deleteCar)
|
||||
mux.HandleFunc("GET /api/cars/{id}/service-records", s.listCarServiceRecords)
|
||||
mux.HandleFunc("GET /api/cars/{id}/technical-checks", s.listCarTechnicalChecks)
|
||||
mux.HandleFunc("GET /api/cars/{id}/parts", s.listCarParts)
|
||||
mux.HandleFunc("GET /api/cars/{id}/fuel-entries", s.listCarFuelEntries)
|
||||
mux.HandleFunc("GET /api/cars/{id}/fuel-stats", s.listCarFuelStats)
|
||||
@@ -258,6 +267,13 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("PATCH /api/service-records/{id}", s.updateServiceRecord)
|
||||
mux.HandleFunc("DELETE /api/service-records/{id}", s.deleteServiceRecord)
|
||||
|
||||
// Technical checks (roadworthiness inspections).
|
||||
mux.HandleFunc("GET /api/technical-checks", s.listTechnicalChecks)
|
||||
mux.HandleFunc("POST /api/technical-checks", s.createTechnicalCheck)
|
||||
mux.HandleFunc("GET /api/technical-checks/{id}", s.getTechnicalCheck)
|
||||
mux.HandleFunc("PATCH /api/technical-checks/{id}", s.updateTechnicalCheck)
|
||||
mux.HandleFunc("DELETE /api/technical-checks/{id}", s.deleteTechnicalCheck)
|
||||
|
||||
// Parts.
|
||||
mux.HandleFunc("GET /api/parts", s.listParts)
|
||||
mux.HandleFunc("POST /api/parts", s.createPart)
|
||||
@@ -300,6 +316,7 @@ func (s *Server) Handler() http.Handler {
|
||||
// alongside is what renders the record after an upload.
|
||||
s.attachmentRoutes(mux, "/api/car-documents", colDocuments, s.getDocument)
|
||||
s.attachmentRoutes(mux, "/api/service-records", colServices, s.getServiceRecord)
|
||||
s.attachmentRoutes(mux, "/api/technical-checks", colTechnicalChecks, s.getTechnicalCheck)
|
||||
s.attachmentRoutes(mux, "/api/maintenance", colMaintenance, s.getMaintenance)
|
||||
s.attachmentRoutes(mux, "/api/fuel-entries", colFuel, s.getFuelEntry)
|
||||
s.attachmentRoutes(mux, "/api/parts", colParts, s.getPart)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// Technical check history: the mandatory roadworthiness inspections a car has
|
||||
// been through — przegląd techniczny, MOT, TÜV.
|
||||
//
|
||||
// Shaped like the service log next to it, but time-only: an inspection falls due
|
||||
// on a date regardless of the odometer. The next-due date is assessed live on
|
||||
// every read (models.TechnicalCheck.ComputeTechnicalCheckDerived) rather than
|
||||
// stored, for the same reason documents are — a lapsed certificate is a car that
|
||||
// cannot legally be driven, and a stored verdict would quietly go stale.
|
||||
//
|
||||
// The certificate scan is handled by attachments.go, on the same terms as every
|
||||
// other record's attachment.
|
||||
|
||||
var technicalCheckResults = map[string]bool{"passed": true, "failed": true}
|
||||
|
||||
// listCarTechnicalChecks serves GET /api/cars/{id}/technical-checks.
|
||||
func (s *Server) listCarTechnicalChecks(w http.ResponseWriter, r *http.Request) {
|
||||
carID := r.PathValue("id")
|
||||
if !s.requireCarAccess(w, r, carID, accessRead) {
|
||||
return
|
||||
}
|
||||
s.respondTechnicalCheckList(w, r, carID)
|
||||
}
|
||||
|
||||
// listTechnicalChecks serves GET /api/technical-checks?car={id}.
|
||||
func (s *Server) listTechnicalChecks(w http.ResponseWriter, r *http.Request) {
|
||||
carID := r.URL.Query().Get("car")
|
||||
if !s.requireCarAccess(w, r, carID, accessRead) {
|
||||
return
|
||||
}
|
||||
s.respondTechnicalCheckList(w, r, carID)
|
||||
}
|
||||
|
||||
func (s *Server) respondTechnicalCheckList(w http.ResponseWriter, r *http.Request, carID string) {
|
||||
q := url.Values{}
|
||||
q.Set("sort", "-date") // most-recent check first, like the service log
|
||||
q.Set("perPage", "500")
|
||||
q.Set("filter", fmt.Sprintf("car='%s'", carID))
|
||||
|
||||
res, err := s.pb.List(r.Context(), colTechnicalChecks, q)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var recs []technicalCheckRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cars := newCarCache(s)
|
||||
now := time.Now()
|
||||
out := make([]models.TechnicalCheck, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
m := rec.toModel()
|
||||
car, err := cars.get(r.Context(), m.Car)
|
||||
if err != nil {
|
||||
car = nil // fall back to the default interval rather than dropping the row
|
||||
}
|
||||
m.ComputeTechnicalCheckDerived(car, now)
|
||||
out = append(out, m)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) getTechnicalCheck(w http.ResponseWriter, r *http.Request) {
|
||||
var rec technicalCheckRecord
|
||||
if err := s.pb.GetOne(r.Context(), colTechnicalChecks, r.PathValue("id"), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
m := rec.toModel()
|
||||
if !s.requireCarAccess(w, r, m.Car, accessRead) {
|
||||
return
|
||||
}
|
||||
s.writeTechnicalCheck(w, r, m, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) createTechnicalCheck(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.TechnicalCheck
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Result == "" {
|
||||
in.Result = "passed"
|
||||
}
|
||||
if msg := validateTechnicalCheck(in); msg != "" {
|
||||
writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, in.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
|
||||
var rec technicalCheckRecord
|
||||
if err := s.pb.Create(r.Context(), colTechnicalChecks, technicalCheckPayload(in), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
s.writeTechnicalCheck(w, r, rec.toModel(), http.StatusCreated)
|
||||
}
|
||||
|
||||
func (s *Server) updateTechnicalCheck(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.TechnicalCheck
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// Enforce write access via the record's existing parent car, so the body's
|
||||
// car field cannot be used to escape into another user's car.
|
||||
var existing technicalCheckRecord
|
||||
if err := s.pb.GetOne(r.Context(), colTechnicalChecks, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
in.Car = existing.Car // a check's car is not reassignable via PATCH
|
||||
if in.Result == "" {
|
||||
in.Result = "passed"
|
||||
}
|
||||
if msg := validateTechnicalCheck(in); msg != "" {
|
||||
writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
|
||||
var rec technicalCheckRecord
|
||||
if err := s.pb.Update(r.Context(), colTechnicalChecks, r.PathValue("id"), technicalCheckPayload(in), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
s.writeTechnicalCheck(w, r, rec.toModel(), http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) deleteTechnicalCheck(w http.ResponseWriter, r *http.Request) {
|
||||
var existing technicalCheckRecord
|
||||
if err := s.pb.GetOne(r.Context(), colTechnicalChecks, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), colTechnicalChecks, r.PathValue("id")); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// writeTechnicalCheck derives the next-due date against the parent car and
|
||||
// responds. Shared by the three handlers that return a single check.
|
||||
func (s *Server) writeTechnicalCheck(w http.ResponseWriter, r *http.Request, m models.TechnicalCheck, status int) {
|
||||
car, err := s.carModel(r.Context(), m.Car)
|
||||
if err != nil {
|
||||
car = nil
|
||||
}
|
||||
m.ComputeTechnicalCheckDerived(car, time.Now())
|
||||
writeJSON(w, status, m)
|
||||
}
|
||||
|
||||
func validateTechnicalCheck(t models.TechnicalCheck) string {
|
||||
switch {
|
||||
case t.Car == "":
|
||||
return "car is required"
|
||||
case t.Date.IsZero():
|
||||
return "date is required"
|
||||
case !technicalCheckResults[t.Result]:
|
||||
return "result must be passed or failed"
|
||||
case t.Cost < 0:
|
||||
return "cost cannot be negative"
|
||||
case len(strings.TrimSpace(t.Station)) > 200:
|
||||
return "station name is too long"
|
||||
}
|
||||
if t.ValidUntil != nil && !t.ValidUntil.IsZero() && t.ValidUntil.Before(t.Date) {
|
||||
return "valid-until date cannot be before the check date"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -24,11 +24,11 @@ type Attachment struct {
|
||||
|
||||
// Car corresponds to one worksheet in the original spreadsheet.
|
||||
type Car struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // e.g. "Toyota Yaris"
|
||||
Make string `json:"make"` // e.g. "Toyota"
|
||||
Model string `json:"model"` // e.g. "Yaris"
|
||||
Year int `json:"year"` // optional
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // e.g. "Toyota Yaris"
|
||||
Make string `json:"make"` // e.g. "Toyota"
|
||||
Model string `json:"model"` // e.g. "Yaris"
|
||||
Year int `json:"year"` // optional
|
||||
Registration string `json:"registration"` // optional plate
|
||||
RegistrationCountry string `json:"registrationCountry"` // optional (country of registration)
|
||||
VIN string `json:"vin"` // optional
|
||||
@@ -38,6 +38,12 @@ type Car struct {
|
||||
ServiceIntervalDays int `json:"serviceIntervalDays"`
|
||||
ServiceIntervalKm int `json:"serviceIntervalKm"`
|
||||
|
||||
// TechnicalCheckIntervalDays is the roadworthiness inspection cycle. It only
|
||||
// prefills the next date — the interval is set by law, not by the car, and
|
||||
// changes as the car ages, so any check can override it with the date its
|
||||
// certificate actually carries.
|
||||
TechnicalCheckIntervalDays int `json:"technicalCheckIntervalDays"`
|
||||
|
||||
// CurrentKm is the car's present odometer reading, updated by the user. Used
|
||||
// to flag km-based overdue service (current_km >= last service km + interval).
|
||||
CurrentKm int `json:"currentKm"`
|
||||
@@ -87,6 +93,40 @@ type ServiceRecord struct {
|
||||
Updated string `json:"updated,omitempty"`
|
||||
}
|
||||
|
||||
// TechnicalCheck is one mandatory roadworthiness inspection in a car's history —
|
||||
// przegląd techniczny, MOT, TÜV, contrôle technique, depending on where the car
|
||||
// is registered.
|
||||
//
|
||||
// It is shaped like a ServiceRecord, but recurs on time alone: an inspection
|
||||
// falls due on a date whatever the odometer says. The cycle is a legal one
|
||||
// rather than a property of the car, and it is not constant — a new car's first
|
||||
// check falls due years later than its third — so the car's interval is only a
|
||||
// default, and ValidUntil overrides it per record.
|
||||
type TechnicalCheck struct {
|
||||
ID string `json:"id"`
|
||||
Car string `json:"car"` // relation -> Car.ID
|
||||
Date time.Time `json:"date"` // date of the inspection
|
||||
|
||||
Result string `json:"result"` // passed | failed
|
||||
Cost float64 `json:"cost"`
|
||||
Station string `json:"station,omitempty"` // inspection station / inspector
|
||||
Notes string `json:"notes,omitempty"`
|
||||
|
||||
// ValidUntil is the expiry printed on the certificate. When set it wins over
|
||||
// the car's interval, because it is the date that actually governs.
|
||||
ValidUntil *time.Time `json:"validUntil,omitempty"`
|
||||
|
||||
// The certificate itself.
|
||||
Attachment
|
||||
|
||||
// Derived (not stored): filled in by the API on read.
|
||||
NextCheckDate *time.Time `json:"nextCheckDate,omitempty"`
|
||||
Expiry ExpiryAssessment `json:"expiry"`
|
||||
|
||||
Created string `json:"created,omitempty"`
|
||||
Updated string `json:"updated,omitempty"`
|
||||
}
|
||||
|
||||
// Part is one entry in a car's parts catalog (Excel cols M/N).
|
||||
type Part struct {
|
||||
ID string `json:"id"`
|
||||
@@ -262,7 +302,7 @@ type Reminder struct {
|
||||
Status string `json:"status"` // done | overdue | due_soon | upcoming | no_trigger
|
||||
DaysLeft *int `json:"daysLeft,omitempty"` // nil when there is no due date
|
||||
KmLeft *int `json:"kmLeft,omitempty"` // nil when there is no due km / no odometer
|
||||
Auto bool `json:"auto"` // true = derived from a document/service, read-only
|
||||
Auto bool `json:"auto"` // true = derived from a document/service, read-only
|
||||
SourceRef string `json:"sourceRef,omitempty"` // id of the record an auto reminder came from
|
||||
|
||||
Created string `json:"created,omitempty"`
|
||||
@@ -325,6 +365,50 @@ func (r *ServiceRecord) ComputeDerived(c *Car) {
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultTechnicalCheckIntervalDays is the annual cycle most of Europe settles
|
||||
// into once a car is a few years old. It is only a starting point: see
|
||||
// Car.TechnicalCheckIntervalDays.
|
||||
const DefaultTechnicalCheckIntervalDays = 365
|
||||
|
||||
// ComputeTechnicalCheckDerived fills NextCheckDate and the expiry assessment.
|
||||
//
|
||||
// The date on the certificate wins over the car's interval when present; the
|
||||
// interval only covers records entered without one. A failed inspection
|
||||
// certifies nothing, so it yields no next date at all — reading one off a
|
||||
// failure would put a reassuring "valid until" on a car that just flunked.
|
||||
func (t *TechnicalCheck) ComputeTechnicalCheckDerived(c *Car, now time.Time) {
|
||||
t.NextCheckDate = nil
|
||||
t.Expiry = ExpiryAssessment{State: "no_expiry"}
|
||||
if t.Result == "failed" {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case t.ValidUntil != nil && !t.ValidUntil.IsZero():
|
||||
d := *t.ValidUntil
|
||||
t.NextCheckDate = &d
|
||||
case !t.Date.IsZero():
|
||||
days := DefaultTechnicalCheckIntervalDays
|
||||
if c != nil && c.TechnicalCheckIntervalDays > 0 {
|
||||
days = c.TechnicalCheckIntervalDays
|
||||
}
|
||||
d := t.Date.AddDate(0, 0, days)
|
||||
t.NextCheckDate = &d
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
days := daysBetween(now, *t.NextCheckDate)
|
||||
state := "valid"
|
||||
switch {
|
||||
case days < 0:
|
||||
state = "expired"
|
||||
case days <= SoonDays:
|
||||
state = "expiring_soon"
|
||||
}
|
||||
t.Expiry = ExpiryAssessment{State: state, Days: &days}
|
||||
}
|
||||
|
||||
// SoonDays is the window within which an upcoming expiry or reminder is
|
||||
// surfaced as "due soon" rather than merely upcoming.
|
||||
const SoonDays = 30
|
||||
|
||||
@@ -236,6 +236,9 @@ const DESIRED = {
|
||||
F.text("vin"),
|
||||
F.number("service_interval_days"),
|
||||
F.number("service_interval_km"),
|
||||
// Roadworthiness inspection cycle. Only prefills a check's next-due date —
|
||||
// the legal interval changes as the car ages, so each check can override it.
|
||||
F.number("technical_check_interval_days"),
|
||||
F.text("oil_spec"),
|
||||
F.text("transmission_oil_spec"),
|
||||
F.text("differential_oil_spec"),
|
||||
@@ -261,6 +264,20 @@ const DESIRED = {
|
||||
F.text("notes"),
|
||||
attachment(), // the workshop receipt / stamped service-book page
|
||||
],
|
||||
// Mandatory roadworthiness inspections (przegląd techniczny / MOT / TÜV).
|
||||
// Like service_records but time-only: a check falls due on a date whatever the
|
||||
// odometer reads. valid_until is the expiry printed on the certificate; when
|
||||
// blank the API derives it from the car's interval.
|
||||
technical_checks: [
|
||||
F.relation("car", "cars", true),
|
||||
F.date("date", true),
|
||||
F.select("result", ["passed", "failed"]),
|
||||
F.number("cost"),
|
||||
F.text("station"),
|
||||
F.date("valid_until"),
|
||||
F.text("notes"),
|
||||
attachment(), // the certificate
|
||||
],
|
||||
parts: [
|
||||
F.relation("car", "cars", true),
|
||||
F.text("name", true),
|
||||
@@ -390,6 +407,8 @@ const INDEXES = {
|
||||
maintenance_entries: ["CREATE INDEX `idx_maintenance_entries_car_date` ON `maintenance_entries` (`car`, `date`)"],
|
||||
car_documents: ["CREATE INDEX `idx_car_documents_car_expiry` ON `car_documents` (`car`, `expiry_date`)"],
|
||||
reminders: ["CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"],
|
||||
// Read as "this car's checks, newest first" every time.
|
||||
technical_checks: ["CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"],
|
||||
};
|
||||
|
||||
async function main() {
|
||||
@@ -411,6 +430,7 @@ async function main() {
|
||||
"organizations",
|
||||
"cars",
|
||||
"service_records",
|
||||
"technical_checks",
|
||||
"parts",
|
||||
"car_shares",
|
||||
"fuel_entries",
|
||||
@@ -434,6 +454,7 @@ async function main() {
|
||||
"users",
|
||||
"cars",
|
||||
"service_records",
|
||||
"technical_checks",
|
||||
"parts",
|
||||
"car_shares",
|
||||
"fuel_entries",
|
||||
@@ -445,8 +466,9 @@ async function main() {
|
||||
}
|
||||
|
||||
console.log(
|
||||
"\nDone. Collections ready: organizations, users, cars, service_records, parts,\n" +
|
||||
"car_shares, fuel_entries, maintenance_entries, car_documents, reminders.",
|
||||
"\nDone. Collections ready: organizations, users, cars, service_records,\n" +
|
||||
"technical_checks, parts, car_shares, fuel_entries, maintenance_entries,\n" +
|
||||
"car_documents, reminders.",
|
||||
);
|
||||
console.log(
|
||||
"Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" +
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user