Service history: columns you can switch off and rearrange
A car's page has let you choose and arrange two things for a while - which tabs
it shows, and which rows the Information tab lists, both dragged into whatever
order you like. The Service history table was left out of that: nine columns,
hardcoded, in one order, for every car. An EV shows Oil & Oil filter and Engine
air filter on every row of a history that will never record either, and a reader
who mostly wants Notes has to look past four columns of dates and distances to
reach it.
It works the way the other two do, because a third mechanism for the same idea
would be one to keep in step. Both lists are properties of the car, so everyone
it is shared with sees the same table, and both need write access to set. The
columns are stored as the hidden set rather than the visible one, so a column
added in a later release is on by default. The arrangement covers the hidden
columns too, which is what makes a column switched back on return to where it
was instead of reappearing at the end - verified below, since that is the part
of this shape that is easy to get wrong and invisible until somebody hits it.
Date cannot be switched off. Every row of that table is work done on a day, and
a history with the day taken out stops being a history; it can still be dragged
anywhere, which is exactly the rule Information already follows in the tab bar.
That is a judgment call and the annotation that prompted this only circled the
other eight columns - moving "date" into hideableServiceColumns and dropping the
filter in the picker would reverse it in two lines if it turns out to be wrong.
Server: hidden_service_columns and service_column_order on the car, validated
against their own key sets by the endpoint that already does this for tabs,
fields and readings. The arrangeable set is derived from the hideable one plus
the date rather than written out again, so the two cannot drift as columns are
added. Bootstrap appends missing fields to existing collections, so the two
columns appear on the next server start with no migration to run.
Web: the table stopped being nine hardcoded th/td pairs and is now driven by one
list of columns, head and body from the same source, which is what stops a moved
or hidden column from shifting the headings out of line with the cells. The
cells are built a row at a time rather than a call per cell, so a long history
doesn't rebuild every cell three times to read its text, its classes and whether
it is the file column. The column headings kept their existing car.services.col*
translations - the keys are mapped rather than derived, because renaming a dozen
strings in three languages to save a lookup table would be the wrong trade. Four
new strings in all three languages.
Verified: go vet and go test ./... pass, with new tests covering both key sets -
that hiding the date is refused, that a field key is not a column key, and that
the arrangeable set is the hideable one plus the date. npm run build is clean.
The page itself was driven in a browser against a throwaway stub API: the
rewritten table renders identically to the hardcoded one, switching two columns
off removed exactly those two from head and body with the rest still aligned and
sent {"hiddenServiceColumns":["oil","engineFilter"]}, dragging Notes onto Km
reordered head and body live and saved an order with the hidden columns still
holding their places, switching Oil back on returned it between Next km and
Cabin air filter rather than to the end, and a read-only share gets no gear
button, no draggable headings and no drag hint.
Not verified: the drag was exercised by dispatching drag events at the
component's own handlers, not by a pointer - the browser pane was not
compositing, which rules out both screenshots and a real drag - so the native
drag image and cursor are unchecked. No automated test guards any of the web
behaviour; the web app still has no test runner. The API rejects unknown JSON
fields, so this web build against an older API Server would take a 400 when
saving the picker: they deploy together from this repo, but one must not ship
without the other. The phone app is deliberately untouched, having no column
table to arrange, and ignores both new fields.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
caf4d2996d
commit
b60d929ed6
@@ -194,8 +194,9 @@ POST /api/vehicle-providers/{provider}/import
|
||||
# which PATCH /api/me {carOrder} sets)
|
||||
GET /api/cars POST /api/cars
|
||||
GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id}
|
||||
PUT /api/cars/{id}/view # which tabs + Information rows this car shows, and
|
||||
# the order of the tabs, the rows and the provider readings
|
||||
PUT /api/cars/{id}/view # which tabs, Information rows and service-history
|
||||
# columns this car shows, and the order of the tabs,
|
||||
# the rows, the columns and the provider readings
|
||||
GET /api/cars/{id}/provider POST /api/cars/{id}/provider
|
||||
POST /api/cars/{id}/provider/sync
|
||||
GET /api/cars/{id}/service-records GET /api/cars/{id}/technical-checks
|
||||
|
||||
@@ -271,6 +271,30 @@ var hideableCarFields = map[string]bool{
|
||||
"vin": true, "fuelType": true, "buildDate": true, "firstRegistration": true,
|
||||
}
|
||||
|
||||
// hideableServiceColumns are the columns of the Service history table that can
|
||||
// be switched off. Date is deliberately not among them: every row of that table
|
||||
// is a service that happened on a day, and a history with the day taken out
|
||||
// stops being a history. Mirrors the car.services.col* labels the web app
|
||||
// renders.
|
||||
var hideableServiceColumns = map[string]bool{
|
||||
"km": true, "nextDate": true, "nextKm": true, "oil": true,
|
||||
"engineFilter": true, "cabinFilter": true, "notes": true, "file": true,
|
||||
}
|
||||
|
||||
// arrangeableServiceColumns are the columns that table can be rearranged into:
|
||||
// the hideable ones plus Date, which cannot be switched off but has no reason to
|
||||
// be stuck at the left. Derived from hideableServiceColumns so the two sets
|
||||
// cannot drift as columns are added — the same construction arrangeableCarTabs
|
||||
// uses for Information.
|
||||
var arrangeableServiceColumns = func() map[string]bool {
|
||||
out := make(map[string]bool, len(hideableServiceColumns)+1)
|
||||
for key := range hideableServiceColumns {
|
||||
out[key] = true
|
||||
}
|
||||
out["date"] = true
|
||||
return out
|
||||
}()
|
||||
|
||||
// arrangeableCarMetrics are the headline readings on the connected-service tab,
|
||||
// and so the keys a car's arrangement of them may name. Derived from the reading
|
||||
// specs in vehicleproviders.go rather than written out again, so the set cannot
|
||||
@@ -308,21 +332,25 @@ func normalizeKeys(in []string, allowed map[string]bool, what string) ([]string,
|
||||
}
|
||||
|
||||
// PUT /api/cars/{id}/view — choose what this car's page shows: which tabs, which
|
||||
// rows of the Information tab, and the order the tabs, the Information rows and
|
||||
// the connected service's headline readings are laid out in. Body: {hiddenTabs?:
|
||||
// [...], hiddenFields?: [...], tabOrder?: [...], fieldOrder?: [...],
|
||||
// metricOrder?: [...]}; only the lists present are written, so a client can
|
||||
// rearrange one group without resending the others. Its own endpoint rather than fields on the car edit, so an ordinary
|
||||
// rows of the Information tab, which columns of the Service history table, and
|
||||
// the order the tabs, the Information rows, those columns and the connected
|
||||
// service's headline readings are laid out in. Body: {hiddenTabs?: [...],
|
||||
// hiddenFields?: [...], hiddenServiceColumns?: [...], tabOrder?: [...],
|
||||
// fieldOrder?: [...], serviceColumnOrder?: [...], metricOrder?: [...]}; only the
|
||||
// lists present are written, so a client can rearrange one group without
|
||||
// resending the others. Its own endpoint rather than fields on the car edit, so an ordinary
|
||||
// save of the car form — which sends every other field — can never reveal
|
||||
// something somebody deliberately switched off. Needs write access: the choice
|
||||
// belongs to the car, so it is the same permission as editing it.
|
||||
func (s *Server) updateCarView(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
HiddenTabs *[]string `json:"hiddenTabs"`
|
||||
HiddenFields *[]string `json:"hiddenFields"`
|
||||
TabOrder *[]string `json:"tabOrder"`
|
||||
FieldOrder *[]string `json:"fieldOrder"`
|
||||
MetricOrder *[]string `json:"metricOrder"`
|
||||
HiddenTabs *[]string `json:"hiddenTabs"`
|
||||
HiddenFields *[]string `json:"hiddenFields"`
|
||||
HiddenServiceColumns *[]string `json:"hiddenServiceColumns"`
|
||||
TabOrder *[]string `json:"tabOrder"`
|
||||
FieldOrder *[]string `json:"fieldOrder"`
|
||||
ServiceColumnOrder *[]string `json:"serviceColumnOrder"`
|
||||
MetricOrder *[]string `json:"metricOrder"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
@@ -355,6 +383,14 @@ func (s *Server) updateCarView(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
payload["hidden_fields"] = fields
|
||||
}
|
||||
if in.HiddenServiceColumns != nil {
|
||||
columns, err := normalizeKeys(*in.HiddenServiceColumns, hideableServiceColumns, "service column")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
payload["hidden_service_columns"] = columns
|
||||
}
|
||||
if in.TabOrder != nil {
|
||||
// A wider set than the hidden tabs: Information is arrangeable although it
|
||||
// cannot be switched off. A partial list is accepted, and the tabs it
|
||||
@@ -379,6 +415,19 @@ func (s *Server) updateCarView(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
payload["field_order"] = order
|
||||
}
|
||||
if in.ServiceColumnOrder != nil {
|
||||
// A wider set than the hidden columns, for the same reason the tab order
|
||||
// is: Date is arrangeable although it cannot be switched off. A partial
|
||||
// list is accepted, and the columns it leaves out follow the arranged
|
||||
// ones, so a column added in a later release lands at the right-hand end
|
||||
// rather than in the middle of somebody's table.
|
||||
order, err := normalizeKeys(*in.ServiceColumnOrder, arrangeableServiceColumns, "service column")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
payload["service_column_order"] = order
|
||||
}
|
||||
if in.MetricOrder != nil {
|
||||
// A partial list again, and here it is the normal case: the client can
|
||||
// only arrange the readings the provider actually reported, so one it
|
||||
|
||||
@@ -2,11 +2,12 @@ package api
|
||||
|
||||
import "testing"
|
||||
|
||||
// What a car's page shows — which tabs, which rows of the Information tab, and
|
||||
// the order those rows are laid out in — is stored on the car as key lists, so
|
||||
// the validation has to keep them to keys the page actually renders.
|
||||
// Information itself stays out of the hideable tabs: a car with no tabs left
|
||||
// would be a dead end.
|
||||
// What a car's page shows — which tabs, which rows of the Information tab, which
|
||||
// columns of the Service history table, and the order each of those is laid out
|
||||
// in — is stored on the car as key lists, so the validation has to keep them to
|
||||
// keys the page actually renders. Two keys stay out of their hideable set:
|
||||
// Information, because a car with no tabs left would be a dead end, and the
|
||||
// service Date, because a history with the day taken out is not one.
|
||||
|
||||
func TestNormalizeHiddenTabs(t *testing.T) {
|
||||
got, err := normalizeKeys([]string{" fuel ", "parts", "fuel", ""}, hideableCarTabs, "tab")
|
||||
@@ -160,6 +161,69 @@ func TestNormalizeMetricOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHiddenServiceColumns(t *testing.T) {
|
||||
got, err := normalizeKeys([]string{" oil ", "notes", "oil", ""}, hideableServiceColumns, "service column")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeKeys: %v", err)
|
||||
}
|
||||
assertKeys(t, got, []string{"oil", "notes"}) // trimmed, blanks dropped, deduped
|
||||
|
||||
// The date is what a service record is; a table of them without it would be
|
||||
// a list of unattributed work.
|
||||
if _, err := normalizeKeys([]string{"date"}, hideableServiceColumns, "service column"); err == nil {
|
||||
t.Error("normalizeKeys allowed hiding the date column, want an error")
|
||||
}
|
||||
// Neither a field key nor an invented one passes: each set is its own.
|
||||
if _, err := normalizeKeys([]string{"vin"}, hideableServiceColumns, "service column"); err == nil {
|
||||
t.Error("normalizeKeys accepted a field key as a service column, want an error")
|
||||
}
|
||||
if _, err := normalizeKeys([]string{"oil", "nonsense"}, hideableServiceColumns, "service column"); err == nil {
|
||||
t.Error("normalizeKeys accepted an unknown service column, want an error")
|
||||
}
|
||||
|
||||
// The hideable set is the contract the web app's HIDEABLE_SERVICE_COLUMNS
|
||||
// mirrors: every column that table renders beside the date.
|
||||
for _, key := range []string{
|
||||
"km", "nextDate", "nextKm", "oil", "engineFilter", "cabinFilter",
|
||||
"notes", "file",
|
||||
} {
|
||||
if !hideableServiceColumns[key] {
|
||||
t.Errorf("service column %q should be hideable", key)
|
||||
}
|
||||
}
|
||||
if len(hideableServiceColumns) != 8 {
|
||||
t.Errorf("hideableServiceColumns has %d entries, want the 8 columns beside the date", len(hideableServiceColumns))
|
||||
}
|
||||
}
|
||||
|
||||
// The columns arrange against a wider set than they hide against, the way the
|
||||
// tabs do: the date cannot be switched off, but it can be moved off the left.
|
||||
func TestNormalizeServiceColumnOrder(t *testing.T) {
|
||||
got, err := normalizeKeys([]string{"notes", "date", "km"}, arrangeableServiceColumns, "service column")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeKeys: %v", err)
|
||||
}
|
||||
assertKeys(t, got, []string{"notes", "date", "km"})
|
||||
|
||||
for key := range hideableServiceColumns {
|
||||
if !arrangeableServiceColumns[key] {
|
||||
t.Errorf("service column %q should be arrangeable", key)
|
||||
}
|
||||
}
|
||||
if !arrangeableServiceColumns["date"] {
|
||||
t.Error("the date column should be arrangeable even though it cannot be hidden")
|
||||
}
|
||||
if len(arrangeableServiceColumns) != len(hideableServiceColumns)+1 {
|
||||
t.Errorf("arrangeableServiceColumns has %d entries, want the hideable columns plus the date", len(arrangeableServiceColumns))
|
||||
}
|
||||
|
||||
// A partial arrangement is fine — the columns it leaves out follow the
|
||||
// arranged ones — but an invented column is still an error.
|
||||
if _, err := normalizeKeys([]string{"date", "nonsense"}, arrangeableServiceColumns, "service column"); err == nil {
|
||||
t.Error("normalizeKeys accepted an unknown column in an arrangement, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func assertKeys(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<title>DriverVault · API Server</title>
|
||||
<script type="module" crossorigin src="/assets/index-D2BCqgpA.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-E4ifC_ff.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CGuUVjJH.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -68,15 +68,18 @@ type carRecord struct {
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
|
||||
// Switched-off tabs and Information fields, plus the arrangements of the
|
||||
// tabs, the Information rows and the connected service's readings. Raw
|
||||
// Switched-off tabs, Information fields and Service history columns, plus
|
||||
// the arrangements of the tabs, the Information rows, those columns and the
|
||||
// connected service's readings. Raw
|
||||
// because PocketBase hands back whatever a json field holds — null on a car
|
||||
// nobody has configured — which is not a []string.
|
||||
HiddenTabs json.RawMessage `json:"hidden_tabs"`
|
||||
HiddenFields json.RawMessage `json:"hidden_fields"`
|
||||
TabOrder json.RawMessage `json:"tab_order"`
|
||||
FieldOrder json.RawMessage `json:"field_order"`
|
||||
MetricOrder json.RawMessage `json:"metric_order"`
|
||||
HiddenTabs json.RawMessage `json:"hidden_tabs"`
|
||||
HiddenFields json.RawMessage `json:"hidden_fields"`
|
||||
HiddenServiceColumns json.RawMessage `json:"hidden_service_columns"`
|
||||
TabOrder json.RawMessage `json:"tab_order"`
|
||||
FieldOrder json.RawMessage `json:"field_order"`
|
||||
ServiceColumnOrder json.RawMessage `json:"service_column_order"`
|
||||
MetricOrder json.RawMessage `json:"metric_order"`
|
||||
}
|
||||
|
||||
func (rec carRecord) toModel() models.Car {
|
||||
@@ -105,8 +108,10 @@ func (rec carRecord) toModel() models.Car {
|
||||
ProviderVehicleID: rec.ProviderVehicleID,
|
||||
HiddenTabs: decodeStringList(rec.HiddenTabs),
|
||||
HiddenFields: decodeStringList(rec.HiddenFields),
|
||||
HiddenServiceColumns: decodeStringList(rec.HiddenServiceColumns),
|
||||
TabOrder: decodeStringList(rec.TabOrder),
|
||||
FieldOrder: decodeStringList(rec.FieldOrder),
|
||||
ServiceColumnOrder: decodeStringList(rec.ServiceColumnOrder),
|
||||
MetricOrder: decodeStringList(rec.MetricOrder),
|
||||
Owner: rec.Owner,
|
||||
Created: rec.Created,
|
||||
|
||||
@@ -46,11 +46,15 @@ var collectionsSchema = map[string][]fieldDef{
|
||||
// release is on by default. Keys are validated in internal/api/cars.go.
|
||||
fJSON("hidden_tabs", 2000),
|
||||
fJSON("hidden_fields", 2000),
|
||||
// And the columns of the Service history table (["oil"] on an EV, which
|
||||
// has no oil to change). Date is not hideable and so never appears here.
|
||||
fJSON("hidden_service_columns", 2000),
|
||||
// The order the tabs are laid out in, as tab keys, the same for the
|
||||
// Information rows, and the same for the connected service's headline
|
||||
// readings. Empty means the page's own default order.
|
||||
// Information rows, the Service history columns, and the connected
|
||||
// service's headline readings. Empty means the page's own default order.
|
||||
fJSON("tab_order", 2000),
|
||||
fJSON("field_order", 2000),
|
||||
fJSON("service_column_order", 2000),
|
||||
fJSON("metric_order", 2000),
|
||||
// Owner of this car. Non-cascading: deleting a user must not wipe their cars.
|
||||
fRelation("owner", "users", false, false),
|
||||
|
||||
@@ -83,6 +83,19 @@ type Car struct {
|
||||
// arranged ones rather than appearing in the middle.
|
||||
FieldOrder []string `json:"fieldOrder"`
|
||||
|
||||
// HiddenServiceColumns is what the Service history table does not show, as
|
||||
// column keys (["oil", "engineFilter"] on an EV, whose service is neither).
|
||||
// The hidden set like the two above, so a column added later is on by
|
||||
// default, and Date is not among the keys it may name: a service record is
|
||||
// its date, and a table of them without it reads as a list of nothing.
|
||||
HiddenServiceColumns []string `json:"hiddenServiceColumns"`
|
||||
|
||||
// ServiceColumnOrder is the arrangement of those columns, covering the
|
||||
// hidden ones so a column switched back on returns to where it was. It does
|
||||
// include "date", which cannot be switched off but can be moved off the
|
||||
// front — the same rule TabOrder applies to Information.
|
||||
ServiceColumnOrder []string `json:"serviceColumnOrder"`
|
||||
|
||||
// MetricOrder is the same thing for the headline readings on the connected
|
||||
// service's tab, as the reading keys ("odometer", "evRange", …). A reading
|
||||
// the provider didn't report at the time it was arranged simply isn't in the
|
||||
|
||||
@@ -94,7 +94,7 @@ const carsApi = [
|
||||
{ method: "POST", path: "/api/cars", desc: "Create a car" },
|
||||
{ method: "GET", path: "/api/cars/{id}", desc: "Fetch one car" },
|
||||
{ method: "PATCH", path: "/api/cars/{id}", desc: "Update a car" },
|
||||
{ method: "PUT", path: "/api/cars/{id}/view", desc: "Save the car's layout — hidden tabs/fields and their order" },
|
||||
{ method: "PUT", path: "/api/cars/{id}/view", desc: "Save the car's layout — hidden tabs/fields/columns and their order" },
|
||||
{ method: "DELETE", path: "/api/cars/{id}", desc: "Delete a car (owner only)" },
|
||||
{ method: "GET", path: "/api/cars/{id}/service-records", desc: "A car's service history" },
|
||||
{ method: "GET", path: "/api/cars/{id}/technical-checks", desc: "A car's roadworthiness inspections" },
|
||||
|
||||
+10
-3
@@ -101,10 +101,13 @@ Config (`server/.env`, copy from `.env.example`):
|
||||
sections that car's page shows (connected service, service history, technical
|
||||
checks, maintenance, fuel cost, charging cost, documents, parts, reminders —
|
||||
Fuel cost off on an EV and Charging cost off on a petrol car) and which of the
|
||||
14 Information rows it lists (no Differential oil on a car without one). It belongs to the car, so everyone it is shared with sees
|
||||
14 Information rows it lists (no Differential oil on a car without one) and
|
||||
which columns the Service history table shows (no Oil, no Engine air filter on
|
||||
an EV). It belongs to the car, so everyone it is shared with sees
|
||||
the same page; setting it needs write access. Stored as the *hidden* sets, so
|
||||
anything added in a later release is on by default, and the Information tab
|
||||
itself can't be switched off.
|
||||
anything added in a later release is on by default. Two things can't be
|
||||
switched off: the Information tab, and the service Date — a history with the
|
||||
day taken out stops being one.
|
||||
- **Locking the layout** — the padlock in the sidebar, above the theme toggle,
|
||||
holds every arrangement still at once: the garage, a car's tabs, its
|
||||
Information rows, the provider's readings. It is a guard against nudging a
|
||||
@@ -122,6 +125,10 @@ Config (`server/.env`, copy from `.env.example`):
|
||||
into any order, saved on drop. Also a property of the car, and it covers the
|
||||
hidden rows too, so switching one back on returns it to where it was. Same
|
||||
native drag events as the garage, so also pointer-only.
|
||||
- **Arranging the Service history columns** — the column headings on that tab
|
||||
drag into any order, saved on drop, and it covers the hidden columns too. Date
|
||||
is arrangeable although it can't be switched off, the same rule Information
|
||||
follows in the tab bar.
|
||||
- **Arranging the connected service's readings** — the headline readings on that
|
||||
tab drag the same way. Only what the provider reported can be arranged, so a
|
||||
reading that turns up later (an EV range on a car that was parked unplugged)
|
||||
|
||||
@@ -380,7 +380,10 @@
|
||||
"tabsHeading": "Faner",
|
||||
"fieldsHeading": "Oplysninger",
|
||||
"alwaysOn": "{tab} er altid tilgængelig.",
|
||||
"fieldsOrderHint": "Træk felterne på fanen Oplysninger for at ændre deres rækkefølge."
|
||||
"fieldsOrderHint": "Træk felterne på fanen Oplysninger for at ændre deres rækkefølge.",
|
||||
"serviceColumnsHeading": "Kolonner i servicehistorik",
|
||||
"columnAlwaysOn": "{column} vises altid.",
|
||||
"serviceColumnsOrderHint": "Træk kolonneoverskrifterne på fanen Servicehistorik for at ændre deres rækkefølge."
|
||||
},
|
||||
|
||||
"provider": {
|
||||
@@ -462,6 +465,7 @@
|
||||
"colCabinFilter": "Kabinefilter",
|
||||
"colNotes": "Noter",
|
||||
"colFile": "Fil",
|
||||
"dragHint": "Træk en kolonne for at ændre rækkefølgen i bilens servicehistorik.",
|
||||
"confirmDelete": "Slet denne servicepost?"
|
||||
},
|
||||
|
||||
|
||||
@@ -379,7 +379,10 @@
|
||||
"tabsHeading": "Tabs",
|
||||
"fieldsHeading": "Information fields",
|
||||
"alwaysOn": "{tab} is always available.",
|
||||
"fieldsOrderHint": "Drag the fields on the Information tab to change the order they appear in."
|
||||
"fieldsOrderHint": "Drag the fields on the Information tab to change the order they appear in.",
|
||||
"serviceColumnsHeading": "Service history columns",
|
||||
"columnAlwaysOn": "{column} is always shown.",
|
||||
"serviceColumnsOrderHint": "Drag the column headings on the Service history tab to change the order they appear in."
|
||||
},
|
||||
|
||||
"provider": {
|
||||
@@ -461,6 +464,7 @@
|
||||
"colCabinFilter": "Cabin air filter",
|
||||
"colNotes": "Notes",
|
||||
"colFile": "File",
|
||||
"dragHint": "Drag a column to rearrange this car's service history.",
|
||||
"confirmDelete": "Delete this service record?"
|
||||
},
|
||||
|
||||
|
||||
@@ -384,7 +384,10 @@
|
||||
"tabsHeading": "Zakładki",
|
||||
"fieldsHeading": "Pola informacji",
|
||||
"alwaysOn": "Zakładka {tab} jest zawsze dostępna.",
|
||||
"fieldsOrderHint": "Przeciągnij pola na zakładce Informacje, aby zmienić ich kolejność."
|
||||
"fieldsOrderHint": "Przeciągnij pola na zakładce Informacje, aby zmienić ich kolejność.",
|
||||
"serviceColumnsHeading": "Kolumny historii serwisowej",
|
||||
"columnAlwaysOn": "Kolumna {column} jest zawsze widoczna.",
|
||||
"serviceColumnsOrderHint": "Przeciągnij nagłówki kolumn na zakładce Historia serwisowa, aby zmienić ich kolejność."
|
||||
},
|
||||
|
||||
"provider": {
|
||||
@@ -466,6 +469,7 @@
|
||||
"colCabinFilter": "Filtr kabinowy",
|
||||
"colNotes": "Notatki",
|
||||
"colFile": "Plik",
|
||||
"dragHint": "Przeciągnij kolumnę, aby zmienić układ historii serwisowej tego samochodu.",
|
||||
"confirmDelete": "Usunąć ten wpis serwisowy?"
|
||||
},
|
||||
|
||||
|
||||
@@ -241,14 +241,28 @@ const INFO_FIELD_KEYS = [
|
||||
"odometer", "serviceInterval", "nextDue", "registrationPlate",
|
||||
"registrationCountry", "vin", "fuelType", "buildDate", "firstRegistration",
|
||||
];
|
||||
// The Service history columns, in their default order. Keys mirror
|
||||
// hideableServiceColumns/arrangeableServiceColumns in the API's cars.go — the
|
||||
// server rejects anything else. Date is in the list because it can be moved,
|
||||
// but not in HIDEABLE_SERVICE_COLUMNS: a service is the day it happened, and a
|
||||
// table of them with the day taken out stops being a history.
|
||||
const ALL_SERVICE_COLUMN_KEYS = [
|
||||
"date", "km", "nextDate", "nextKm", "oil", "engineFilter", "cabinFilter",
|
||||
"notes", "file",
|
||||
];
|
||||
const HIDEABLE_SERVICE_COLUMNS = ALL_SERVICE_COLUMN_KEYS.filter((key) => key !== "date");
|
||||
const tabDraft = ref([]); // tab keys that stay visible
|
||||
const fieldDraft = ref([]); // Information keys that stay visible
|
||||
const columnDraft = ref([]); // Service history columns that stay visible
|
||||
const viewSaving = ref(false);
|
||||
const viewError = ref("");
|
||||
|
||||
function openViewPicker() {
|
||||
tabDraft.value = HIDEABLE_TABS.filter((key) => !hiddenTabs.value.includes(key));
|
||||
fieldDraft.value = fieldKeys.value.filter((key) => !hiddenFields.value.includes(key));
|
||||
columnDraft.value = serviceColumnKeys.value.filter(
|
||||
(key) => key !== "date" && !hiddenServiceColumns.value.includes(key)
|
||||
);
|
||||
viewError.value = "";
|
||||
showViewPicker.value = true;
|
||||
}
|
||||
@@ -262,6 +276,9 @@ function toggleTabDraft(key, on) {
|
||||
function toggleFieldDraft(key, on) {
|
||||
fieldDraft.value = on ? [...fieldDraft.value, key] : fieldDraft.value.filter((k) => k !== key);
|
||||
}
|
||||
function toggleColumnDraft(key, on) {
|
||||
columnDraft.value = on ? [...columnDraft.value, key] : columnDraft.value.filter((k) => k !== key);
|
||||
}
|
||||
|
||||
async function saveView() {
|
||||
viewSaving.value = true;
|
||||
@@ -270,6 +287,7 @@ async function saveView() {
|
||||
const updated = await api.updateCarView(props.id, {
|
||||
hiddenTabs: HIDEABLE_TABS.filter((key) => !tabDraft.value.includes(key)),
|
||||
hiddenFields: INFO_FIELD_KEYS.filter((key) => !fieldDraft.value.includes(key)),
|
||||
hiddenServiceColumns: HIDEABLE_SERVICE_COLUMNS.filter((key) => !columnDraft.value.includes(key)),
|
||||
});
|
||||
car.value = { ...updated, access: car.value.access };
|
||||
showViewPicker.value = false;
|
||||
@@ -291,6 +309,17 @@ function tabPickerLabel(key) {
|
||||
function infoFieldLabel(key) {
|
||||
return t(`car.info.${key}`);
|
||||
}
|
||||
// The column headings were translated as car.services.col* long before they
|
||||
// became keys, so the two are mapped rather than derived: renaming a dozen
|
||||
// strings in three languages to save this table would be the wrong trade.
|
||||
const SERVICE_COLUMN_LABELS = {
|
||||
date: "colDate", km: "colKm", nextDate: "colNextDate", nextKm: "colNextKm",
|
||||
oil: "colOil", engineFilter: "colEngineFilter", cabinFilter: "colCabinFilter",
|
||||
notes: "colNotes", file: "colFile",
|
||||
};
|
||||
function serviceColumnLabel(key) {
|
||||
return t(`car.services.${SERVICE_COLUMN_LABELS[key]}`);
|
||||
}
|
||||
|
||||
// --- The arrangement of the Information rows ---
|
||||
//
|
||||
@@ -405,6 +434,142 @@ const infoFields = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
// --- The Service history columns ---
|
||||
//
|
||||
// The same three pieces as the Information rows, on the same reasoning: a
|
||||
// hidden set so a column added in a later release is on by default, an
|
||||
// arrangement that covers the hidden columns so one switched back on returns to
|
||||
// where it was, and both properties of the car, so everyone it is shared with
|
||||
// sees the same table.
|
||||
const hiddenServiceColumns = computed(() => car.value?.hiddenServiceColumns || []);
|
||||
const serviceColumnKeys = ref([...ALL_SERVICE_COLUMN_KEYS]);
|
||||
watch(
|
||||
() => car.value?.serviceColumnOrder,
|
||||
(order) => {
|
||||
const arranged = [];
|
||||
for (const key of order || []) {
|
||||
if (ALL_SERVICE_COLUMN_KEYS.includes(key) && !arranged.includes(key)) arranged.push(key);
|
||||
}
|
||||
// A column the stored arrangement doesn't mention follows the arranged ones,
|
||||
// so it joins at the right-hand end rather than in the middle of somebody's
|
||||
// table — the rule the tabs, the Information rows and the garage all use.
|
||||
serviceColumnKeys.value = [
|
||||
...arranged,
|
||||
...ALL_SERVICE_COLUMN_KEYS.filter((k) => !arranged.includes(k)),
|
||||
];
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// The visible columns in their arranged order, as data, so the head and the body
|
||||
// are driven by one list and cannot drift apart when a column moves or goes
|
||||
// away. `center` is for the three yes/no columns, whose heading sits over a
|
||||
// column of two-letter answers.
|
||||
const CENTERED_SERVICE_COLUMNS = ["oil", "engineFilter", "cabinFilter"];
|
||||
const serviceColumns = computed(() =>
|
||||
serviceColumnKeys.value
|
||||
.filter((key) => !hiddenServiceColumns.value.includes(key))
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: serviceColumnLabel(key),
|
||||
center: CENTERED_SERVICE_COLUMNS.includes(key),
|
||||
}))
|
||||
);
|
||||
|
||||
// One cell of that table. Returns the text and the classes it carries beyond the
|
||||
// shared padding; the file column is the one whose cell is a button, and says so
|
||||
// rather than returning text the template would have to special-case by key.
|
||||
function serviceCell(s, key) {
|
||||
switch (key) {
|
||||
case "date":
|
||||
return { text: formatDate(s.date), classes: "whitespace-nowrap data font-medium text-strong" };
|
||||
case "km":
|
||||
return { text: formatKm(s.km), classes: "whitespace-nowrap data text-body" };
|
||||
case "nextDate":
|
||||
return { text: formatDate(s.nextServiceDate), classes: "whitespace-nowrap data text-muted" };
|
||||
case "nextKm":
|
||||
return { text: formatKm(s.nextServiceKm), classes: "whitespace-nowrap data text-muted" };
|
||||
case "oil":
|
||||
return yesNoCell(s.changedOil);
|
||||
case "engineFilter":
|
||||
return yesNoCell(s.changedEngineAirFilter);
|
||||
case "cabinFilter":
|
||||
return yesNoCell(s.changedCabinAirFilter);
|
||||
case "notes":
|
||||
return { text: s.notes || t("common.empty"), classes: "text-body" };
|
||||
default: // file
|
||||
return { file: true, classes: "whitespace-nowrap" };
|
||||
}
|
||||
}
|
||||
function yesNoCell(on) {
|
||||
return {
|
||||
text: yn(on),
|
||||
classes: `text-center text-xs font-semibold ${on ? "text-success" : "text-muted"}`,
|
||||
};
|
||||
}
|
||||
|
||||
// One row's cells, already in the arranged order. A row at a time rather than a
|
||||
// call per cell, so a table of twenty services doesn't rebuild every cell three
|
||||
// times over to read its classes, its text and whether it is the file column.
|
||||
function serviceRow(s) {
|
||||
return serviceColumns.value.map((col) => ({ ...col, ...serviceCell(s, col.key) }));
|
||||
}
|
||||
|
||||
// Dragging a column header, on the same native drag events as the tabs, the
|
||||
// Information rows and the garage — so pointer-only, as touch browsers don't
|
||||
// fire these. Needs write access, since the arrangement belongs to the car, and
|
||||
// there is nothing to rearrange with one column showing.
|
||||
const canArrangeServiceColumns = computed(
|
||||
() => canWrite.value && !prefs.dragLocked && serviceColumns.value.length > 1
|
||||
);
|
||||
const dragColumn = ref(""); // column being dragged
|
||||
const dropColumn = ref(""); // column it is currently hovering over
|
||||
const columnOrderError = ref("");
|
||||
let columnsMoved = false; // the table changed during this drag and isn't saved yet
|
||||
|
||||
function onColumnDragStart(key, e) {
|
||||
dragColumn.value = key;
|
||||
columnsMoved = false;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
// Firefox only starts a drag once something is on the transfer.
|
||||
e.dataTransfer.setData("text/plain", key);
|
||||
}
|
||||
|
||||
// Reorder live as the pointer crosses headers, so the table shows the
|
||||
// arrangement you are about to get. The splice works on the full list, hidden
|
||||
// columns included, which keeps a hidden column anchored between the same two
|
||||
// visible neighbours.
|
||||
function onColumnDragEnter(key) {
|
||||
if (!dragColumn.value || key === dragColumn.value || dropColumn.value === key) return;
|
||||
dropColumn.value = key;
|
||||
const list = serviceColumnKeys.value;
|
||||
const from = list.indexOf(dragColumn.value);
|
||||
const to = list.indexOf(key);
|
||||
if (from < 0 || to < 0) return;
|
||||
list.splice(to, 0, ...list.splice(from, 1));
|
||||
columnsMoved = true;
|
||||
}
|
||||
|
||||
// Save whatever the table now shows. Called from both drop and dragend: a header
|
||||
// released past the end of the row never produces a drop, and leaving that
|
||||
// arrangement unsaved would quietly undo itself on the next load.
|
||||
async function commitServiceColumnOrder() {
|
||||
dragColumn.value = "";
|
||||
dropColumn.value = "";
|
||||
if (!columnsMoved) return;
|
||||
columnsMoved = false;
|
||||
columnOrderError.value = "";
|
||||
try {
|
||||
const updated = await api.updateCarView(props.id, { serviceColumnOrder: serviceColumnKeys.value });
|
||||
car.value = { ...updated, access: car.value.access };
|
||||
} catch (e) {
|
||||
// The arrangement didn't stick; say so and put the stored one back rather
|
||||
// than leaving the page showing an order the server doesn't have.
|
||||
columnOrderError.value = e.message;
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
@@ -901,8 +1066,11 @@ onMounted(load);
|
||||
<p v-if="canArrangeFields" class="mt-2 text-xs text-muted">{{ t("car.info.dragHint") }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Service history -->
|
||||
<!-- Service history. The columns can be switched off in the view picker and
|
||||
dragged into any order with write access; both belong to the car, like
|
||||
which tabs and Information rows it shows. -->
|
||||
<section v-else-if="activeTab === 'services'">
|
||||
<p v-if="columnOrderError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ columnOrderError }}</p>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<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">
|
||||
@@ -919,33 +1087,41 @@ onMounted(load);
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-sunken text-left">
|
||||
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
|
||||
<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-for="col in serviceColumns"
|
||||
:key="col.key"
|
||||
:draggable="canArrangeServiceColumns"
|
||||
:title="canArrangeServiceColumns ? t('car.services.dragHint') : ''"
|
||||
:class="[
|
||||
col.center ? '!text-center' : '',
|
||||
canArrangeServiceColumns ? 'cursor-grab active:cursor-grabbing' : '',
|
||||
dragColumn === col.key ? 'opacity-50' : '',
|
||||
dropColumn === col.key ? 'ring-2 ring-inset ring-accent' : '',
|
||||
]"
|
||||
@dragstart="onColumnDragStart(col.key, $event)"
|
||||
@dragenter.prevent="onColumnDragEnter(col.key)"
|
||||
@dragover.prevent
|
||||
@drop.prevent="commitServiceColumnOrder"
|
||||
@dragend="commitServiceColumnOrder"
|
||||
>{{ col.label }}</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-subtle">
|
||||
<tr v-for="s in services" :key="s.id" class="transition-colors hover:bg-sunken">
|
||||
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">{{ formatDate(s.date) }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ formatKm(s.km) }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ formatDate(s.nextServiceDate) }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ formatKm(s.nextServiceKm) }}</td>
|
||||
<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 || 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)">
|
||||
{{ t("common.download") }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
|
||||
<td
|
||||
v-for="cell in serviceRow(s)"
|
||||
:key="cell.key"
|
||||
class="px-4 py-3"
|
||||
:class="cell.classes"
|
||||
>
|
||||
<template v-if="cell.file">
|
||||
<button v-if="s.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('services', s)">
|
||||
{{ t("common.download") }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
|
||||
</template>
|
||||
<template v-else>{{ cell.text }}</template>
|
||||
</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)">{{ t("common.edit") }}</button>
|
||||
@@ -955,6 +1131,7 @@ onMounted(load);
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="canArrangeServiceColumns" class="mt-2 text-xs text-muted">{{ t("car.services.dragHint") }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Technical check history -->
|
||||
@@ -1579,6 +1756,27 @@ onMounted(load);
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted">{{ t("car.viewPicker.fieldsOrderHint") }}</p>
|
||||
|
||||
<p class="eyebrow mb-2 mt-5">{{ t("car.viewPicker.serviceColumnsHeading") }}</p>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="key in serviceColumnKeys.filter((k) => k !== 'date')"
|
||||
:key="key"
|
||||
class="flex items-center gap-2 text-sm font-medium text-body"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-subtle text-accent focus:ring-accent"
|
||||
:checked="columnDraft.includes(key)"
|
||||
@change="toggleColumnDraft(key, $event.target.checked)"
|
||||
/>
|
||||
<span>{{ serviceColumnLabel(key) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{{ t("car.viewPicker.columnAlwaysOn", { column: t("car.services.colDate") }) }}
|
||||
{{ t("car.viewPicker.serviceColumnsOrderHint") }}
|
||||
</p>
|
||||
|
||||
<p v-if="viewError" class="mt-3 text-sm text-danger">{{ viewError }}</p>
|
||||
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
|
||||
Reference in New Issue
Block a user