diff --git a/API Server/README.md b/API Server/README.md index 6353a6c..4fbaea8 100644 --- a/API Server/README.md +++ b/API Server/README.md @@ -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) | diff --git a/API Server/internal/api/cars.go b/API Server/internal/api/cars.go index 6db6970..317cd48 100644 --- a/API Server/internal/api/cars.go +++ b/API Server/internal/api/cars.go @@ -232,4 +232,7 @@ func applyCarDefaults(c *models.Car) { if c.ServiceIntervalKm <= 0 { c.ServiceIntervalKm = 15000 } + if c.TechnicalCheckIntervalDays <= 0 { + c.TechnicalCheckIntervalDays = models.DefaultTechnicalCheckIntervalDays + } } diff --git a/API Server/internal/api/records.go b/API Server/internal/api/records.go index e3b9e55..0f57b4a 100644 --- a/API Server/internal/api/records.go +++ b/API Server/internal/api/records.go @@ -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. diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index 6a19e95..671149a 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -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) diff --git a/API Server/internal/api/technical.go b/API Server/internal/api/technical.go new file mode 100644 index 0000000..f6546a7 --- /dev/null +++ b/API Server/internal/api/technical.go @@ -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 "" +} diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index 0fd3cad..00b7b02 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -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 diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index ba94367..791049a 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -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" + diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index 5acfa63..96a7bc5 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -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"), diff --git a/Web App/web/src/components/CarFormModal.vue b/Web App/web/src/components/CarFormModal.vue index a3aee0f..229016c 100644 --- a/Web App/web/src/components/CarFormModal.vue +++ b/Web App/web/src/components/CarFormModal.vue @@ -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() { +
+ + +

+ Prefills each check's next-due date. Any check can override it with the date printed on + its certificate. +

+
+ +
+
+
+

Technical check history

+

+ Mandatory roadworthiness inspections. Recurs on time alone, whatever the odometer reads. +

+
+ +
+ +
+ No technical checks yet. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateResultNext checkStatusStationCostNotesFile
{{ formatDate(t.date) }} + + {{ t.result === 'failed' ? 'Failed' : 'Passed' }} + + {{ formatDate(t.nextCheckDate) }} + {{ expiryStatus(t).label }} + {{ t.station || '—' }}{{ t.cost ? formatMoney(t.cost) : '—' }}{{ t.notes || '—' }} + + + + + +
+
+
+
@@ -892,6 +988,14 @@ onMounted(load); @saved="onServiceSaved" @close="showService = false" /> +