Changed parts: one column in the service table, not one each
Oil & Oil filter, Engine air filter and Cabin air filter had a column of Yes/No
each in the Service history table, 375px of the 1022px table between them for
three bits of information. The form has always kept them together in one Changed
parts section, which is the honest shape: they are one answer to one question
about a service, not three unrelated readings. The table said otherwise, and the
list is going to grow - every part added would have taken another column and
pushed the table into a sideways scroll.
They are one column now, 234px with the widest summary on screen, and its width
no longer depends on how many parts exist. The cell names what was changed
rather than counting it, because a history is read down the page and "2" tells
you nothing about which two; past two names it becomes the first part and a
tally, which is what keeps one line one line as the list grows. Nothing changed
reads as an em dash.
The detail is a dropdown, not a dialog. This is read-only detail about one row
of a table you are reading down: a modal would black out the rows being compared
against and charge an open-and-close for each one. It is pinned under the button
it was opened from, closes on an outside click, Escape or a scroll - it is fixed
to a point on the screen, so a table that moves underneath would leave it
pointing at the wrong row - and there is one panel rather than one per row. It
lists every part with a Yes or a No, the unchanged ones included, so the em-dash
row still answers the question instead of being a dead cell.
One list in lib/serviceParts.js now drives the form's checkboxes, the cell's
summary and the panel. That is the point of the change as much as the width is:
adding a part was three edits that had to agree, and is now one entry plus its
boolean on the API's service_records collection. The form builds its state and
its payload from the list rather than naming the three fields twice - the save
payload is unchanged in shape, which was checked against the wire rather than by
reading it.
This walks back part of the previous commit, which had just made all three
hideable separately: the server's column set drops oil/engineFilter/cabinFilter
for a single "parts" key, and a test now asserts those three are not columns of
their own, so the table cannot drift back. A car with ["oil"] stored as hidden
would quietly get the combined column - nothing has that stored, the deployed
stack predating the feature, and stale keys are dropped on read rather than
erroring.
Verified in a browser against a stub API: all four summary cases (one part
named, two named, three as "Oil & Oil filter +2", none as an em dash); the panel
opens anchored under its button with the right Yes/No for the row, stays inside
the window, and closes on outside click, Escape, scroll and a second click,
switching rows without leaving a second panel behind; the picker offers "Changed
parts" as one entry and hiding it sends {"hiddenServiceColumns":["parts"]};
dragging sends "parts" in the order with the hidden column holding its slot; the
Edit dialog renders from the shared list and its PATCH still carries all three
booleans with the unticked one false. go vet, go test ./... and npm run build
are clean.
Not verified: no automated test covers any of it - the web app still has no test
runner, so the cases above were driven by hand. The drag and the panel were
exercised through dispatched events rather than a pointer, the browser pane not
compositing, so the native drag image and the panel's behaviour under a real
click-and-hold are unchecked. The dropdown overlaps the rows beneath it, which
is what a dropdown does but was not weighed against a taller table. The deployed
Web App still shows three columns until it is redeployed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b60d929ed6
commit
c5d431c560
@@ -274,11 +274,12 @@ var hideableCarFields = map[string]bool{
|
||||
// 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.
|
||||
// stops being a history. "parts" is the one column covering every part a service
|
||||
// can have changed — they are a growing list, and one column per part would
|
||||
// widen the table indefinitely — so the set does not grow when a part is added.
|
||||
var hideableServiceColumns = map[string]bool{
|
||||
"km": true, "nextDate": true, "nextKm": true, "oil": true,
|
||||
"engineFilter": true, "cabinFilter": true, "notes": true, "file": true,
|
||||
"km": true, "nextDate": true, "nextKm": true, "parts": true,
|
||||
"notes": true, "file": true,
|
||||
}
|
||||
|
||||
// arrangeableServiceColumns are the columns that table can be rearranged into:
|
||||
|
||||
@@ -162,11 +162,11 @@ func TestNormalizeMetricOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNormalizeHiddenServiceColumns(t *testing.T) {
|
||||
got, err := normalizeKeys([]string{" oil ", "notes", "oil", ""}, hideableServiceColumns, "service column")
|
||||
got, err := normalizeKeys([]string{" parts ", "notes", "parts", ""}, hideableServiceColumns, "service column")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeKeys: %v", err)
|
||||
}
|
||||
assertKeys(t, got, []string{"oil", "notes"}) // trimmed, blanks dropped, deduped
|
||||
assertKeys(t, got, []string{"parts", "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.
|
||||
@@ -177,33 +177,37 @@ func TestNormalizeHiddenServiceColumns(t *testing.T) {
|
||||
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 {
|
||||
if _, err := normalizeKeys([]string{"parts", "nonsense"}, hideableServiceColumns, "service column"); err == nil {
|
||||
t.Error("normalizeKeys accepted an unknown service column, want an error")
|
||||
}
|
||||
// The parts are one column, not one each: a key per part would put the table
|
||||
// back where it started, and these three were columns of their own once.
|
||||
for _, key := range []string{"oil", "engineFilter", "cabinFilter"} {
|
||||
if hideableServiceColumns[key] {
|
||||
t.Errorf("%q should not be a column of its own — the parts share one", key)
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
} {
|
||||
for _, key := range []string{"km", "nextDate", "nextKm", "parts", "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))
|
||||
if len(hideableServiceColumns) != 6 {
|
||||
t.Errorf("hideableServiceColumns has %d entries, want the 6 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")
|
||||
got, err := normalizeKeys([]string{"notes", "date", "parts"}, arrangeableServiceColumns, "service column")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeKeys: %v", err)
|
||||
}
|
||||
assertKeys(t, got, []string{"notes", "date", "km"})
|
||||
assertKeys(t, got, []string{"notes", "date", "parts"})
|
||||
|
||||
for key := range hideableServiceColumns {
|
||||
if !arrangeableServiceColumns[key] {
|
||||
|
||||
@@ -46,8 +46,9 @@ 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.
|
||||
// And the columns of the Service history table (["parts"] for a reader who
|
||||
// never records what was changed). 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, the Service history columns, and the connected
|
||||
|
||||
@@ -84,10 +84,11 @@ type Car struct {
|
||||
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.
|
||||
// column keys (["parts", "file"] for somebody who keeps only dates and
|
||||
// distances). 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
|
||||
|
||||
+10
-2
@@ -102,8 +102,8 @@ Config (`server/.env`, copy from `.env.example`):
|
||||
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) 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
|
||||
which columns the Service history table shows (no File column for somebody who
|
||||
keeps no receipts). 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. Two things can't be
|
||||
switched off: the Information tab, and the service Date — a history with the
|
||||
@@ -125,6 +125,14 @@ 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.
|
||||
- **Changed parts** — every part a service can record sits in one column, not one
|
||||
column each: they are a growing list and a column apiece would widen the table
|
||||
without end. The cell names what was changed (past two, the first and a tally)
|
||||
and opens a panel listing every part with a yes or a no — a dropdown rather
|
||||
than a dialog, so the rows you are comparing it against stay on screen. Both
|
||||
the column and the form's Changed parts section are driven by one list in
|
||||
`lib/serviceParts.js`, so adding a part is one entry there plus its boolean on
|
||||
the API's `service_records` collection.
|
||||
- **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
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from "vue";
|
||||
import { api } from "../api";
|
||||
import { formatKm } from "../lib/format.js";
|
||||
import { applyAttachment } from "../lib/attachment.js";
|
||||
import { SERVICE_PARTS } from "../lib/serviceParts.js";
|
||||
import { t } from "../i18n";
|
||||
import AttachmentField from "./AttachmentField.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
@@ -20,9 +21,13 @@ const error = ref("");
|
||||
const form = ref({
|
||||
date: props.service ? toDateInput(props.service.date) : new Date().toISOString().slice(0, 10),
|
||||
km: props.service?.km ?? "",
|
||||
changedOil: props.service ? props.service.changedOil : true,
|
||||
changedEngineAirFilter: props.service ? props.service.changedEngineAirFilter : false,
|
||||
changedCabinAirFilter: props.service ? props.service.changedCabinAirFilter : false,
|
||||
// One entry per part, built from the shared list rather than named here, so a
|
||||
// part added to it turns up in this dialog without a second edit. An existing
|
||||
// record written before a part existed has no field for it, which reads as
|
||||
// unchecked.
|
||||
...Object.fromEntries(
|
||||
SERVICE_PARTS.map((part) => [part.field, props.service ? !!props.service[part.field] : part.initial])
|
||||
),
|
||||
notes: props.service?.notes ?? "",
|
||||
});
|
||||
|
||||
@@ -44,9 +49,7 @@ async function submit() {
|
||||
// Blank-tested, not truthiness-tested: a service logged at 0 km on a car
|
||||
// collected new is a real entry, and the field is required anyway.
|
||||
km: form.value.km === "" ? 0 : Number(form.value.km),
|
||||
changedOil: form.value.changedOil,
|
||||
changedEngineAirFilter: form.value.changedEngineAirFilter,
|
||||
changedCabinAirFilter: form.value.changedCabinAirFilter,
|
||||
...Object.fromEntries(SERVICE_PARTS.map((part) => [part.field, form.value[part.field]])),
|
||||
notes: form.value.notes.trim(),
|
||||
};
|
||||
const saved = isEdit
|
||||
@@ -80,9 +83,14 @@ async function submit() {
|
||||
</div>
|
||||
<fieldset class="rounded-control border border-subtle p-3">
|
||||
<legend class="eyebrow px-1">{{ t("forms.service.changedParts") }}</legend>
|
||||
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedOil" class="accent-[var(--accent)]" /> {{ t("forms.service.oil") }}</label>
|
||||
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedEngineAirFilter" class="accent-[var(--accent)]" /> {{ t("forms.service.engineFilter") }}</label>
|
||||
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedCabinAirFilter" class="accent-[var(--accent)]" /> {{ t("forms.service.cabinFilter") }}</label>
|
||||
<label
|
||||
v-for="part in SERVICE_PARTS"
|
||||
:key="part.key"
|
||||
class="flex items-center gap-2 py-1 text-sm text-body"
|
||||
>
|
||||
<input type="checkbox" v-model="form[part.field]" class="accent-[var(--accent)]" />
|
||||
{{ t(part.label) }}
|
||||
</label>
|
||||
</fieldset>
|
||||
<AttachmentField
|
||||
v-model:file="file"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// The parts a service record can say were changed.
|
||||
//
|
||||
// One list, because each part has to appear in three places at once — the
|
||||
// Changed parts section of the service form, the column of the Service history
|
||||
// table, and the panel that column opens — and three hand-kept copies would
|
||||
// drift the first time somebody adds a part. Adding one here (plus its boolean
|
||||
// on the API's service_records collection) is the whole job.
|
||||
//
|
||||
// `field` is the record's own boolean; `label` is the translation key, shared
|
||||
// with the form so the table and the dialog can't end up wording the same part
|
||||
// differently. `initial` is what a *new* record starts with: an oil change is
|
||||
// the reason for most services, the filters are the exception.
|
||||
export const SERVICE_PARTS = [
|
||||
{ key: "oil", field: "changedOil", label: "forms.service.oil", initial: true },
|
||||
{ key: "engineFilter", field: "changedEngineAirFilter", label: "forms.service.engineFilter", initial: false },
|
||||
{ key: "cabinFilter", field: "changedCabinAirFilter", label: "forms.service.cabinFilter", initial: false },
|
||||
];
|
||||
|
||||
// The parts this record says were changed. A record written before a part
|
||||
// existed simply doesn't carry its field, which reads as "not changed" rather
|
||||
// than as a missing value — that service genuinely didn't change it.
|
||||
export function changedParts(service) {
|
||||
return SERVICE_PARTS.filter((part) => !!service?.[part.field]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from "vue";
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { prefs } from "../prefs";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
expiryStatus,
|
||||
reminderStatus,
|
||||
} from "../lib/format.js";
|
||||
import { SERVICE_PARTS, changedParts } from "../lib/serviceParts.js";
|
||||
import { t, tSplit } from "../i18n";
|
||||
import CarFormModal from "../components/CarFormModal.vue";
|
||||
import ServiceFormModal from "../components/ServiceFormModal.vue";
|
||||
@@ -247,8 +248,7 @@ const INFO_FIELD_KEYS = [
|
||||
// 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",
|
||||
"date", "km", "nextDate", "nextKm", "parts", "notes", "file",
|
||||
];
|
||||
const HIDEABLE_SERVICE_COLUMNS = ALL_SERVICE_COLUMN_KEYS.filter((key) => key !== "date");
|
||||
const tabDraft = ref([]); // tab keys that stay visible
|
||||
@@ -311,14 +311,21 @@ function infoFieldLabel(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.
|
||||
// strings in three languages to save this table would be the wrong trade. The
|
||||
// parts column borrows the form's heading on purpose — the column and the
|
||||
// dialog's section are the same thing, and they should not drift into wording
|
||||
// it differently.
|
||||
const SERVICE_COLUMN_LABELS = {
|
||||
date: "colDate", km: "colKm", nextDate: "colNextDate", nextKm: "colNextKm",
|
||||
oil: "colOil", engineFilter: "colEngineFilter", cabinFilter: "colCabinFilter",
|
||||
notes: "colNotes", file: "colFile",
|
||||
date: "car.services.colDate",
|
||||
km: "car.services.colKm",
|
||||
nextDate: "car.services.colNextDate",
|
||||
nextKm: "car.services.colNextKm",
|
||||
parts: "forms.service.changedParts",
|
||||
notes: "car.services.colNotes",
|
||||
file: "car.services.colFile",
|
||||
};
|
||||
function serviceColumnLabel(key) {
|
||||
return t(`car.services.${SERVICE_COLUMN_LABELS[key]}`);
|
||||
return t(SERVICE_COLUMN_LABELS[key]);
|
||||
}
|
||||
|
||||
// --- The arrangement of the Information rows ---
|
||||
@@ -463,17 +470,11 @@ watch(
|
||||
|
||||
// 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"];
|
||||
// away.
|
||||
const serviceColumns = computed(() =>
|
||||
serviceColumnKeys.value
|
||||
.filter((key) => !hiddenServiceColumns.value.includes(key))
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: serviceColumnLabel(key),
|
||||
center: CENTERED_SERVICE_COLUMNS.includes(key),
|
||||
}))
|
||||
.map((key) => ({ key, label: serviceColumnLabel(key) }))
|
||||
);
|
||||
|
||||
// One cell of that table. Returns the text and the classes it carries beyond the
|
||||
@@ -489,22 +490,26 @@ function serviceCell(s, key) {
|
||||
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 "parts":
|
||||
return { parts: partsSummary(s), classes: "whitespace-nowrap" };
|
||||
case "notes":
|
||||
return { text: s.notes || t("common.empty"), classes: "text-body" };
|
||||
default: // file
|
||||
return { file: true, classes: "whitespace-nowrap" };
|
||||
}
|
||||
}
|
||||
function yesNoCell(on) {
|
||||
// What the Changed parts cell says before it is opened. Naming the parts beats a
|
||||
// bare count — the point of a history is to be read down the page — but the list
|
||||
// has to stay one line wide, and it is going to grow, so past two it becomes the
|
||||
// first part and a tally. The panel behind it has the full picture either way.
|
||||
function partsSummary(s) {
|
||||
const changed = changedParts(s);
|
||||
if (!changed.length) return { text: t("common.empty"), muted: true, count: 0 };
|
||||
const labels = changed.map((part) => t(part.label));
|
||||
return {
|
||||
text: yn(on),
|
||||
classes: `text-center text-xs font-semibold ${on ? "text-success" : "text-muted"}`,
|
||||
text: labels.length > 2 ? `${labels[0]} +${labels.length - 1}` : labels.join(", "),
|
||||
muted: false,
|
||||
count: changed.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -515,6 +520,58 @@ function serviceRow(s) {
|
||||
return serviceColumns.value.map((col) => ({ ...col, ...serviceCell(s, col.key) }));
|
||||
}
|
||||
|
||||
// --- The Changed parts panel ---
|
||||
//
|
||||
// A dropdown rather than a dialog. This is read-only detail about one row of a
|
||||
// table meant to be read down the page: a modal would black out the rows you
|
||||
// are comparing against and cost an open-and-close for every one of them. It is
|
||||
// positioned fixed from the button's own rectangle because the table scrolls
|
||||
// sideways inside a clipping box, which an absolutely positioned panel could
|
||||
// not get out of.
|
||||
const openParts = ref(""); // id of the record whose panel is open
|
||||
const partsAt = ref({ left: 0, top: 0 });
|
||||
const PARTS_PANEL_WIDTH = 240; // keep in step with the panel's w-60 below
|
||||
|
||||
// One panel, not one per row: it is fixed to a point on the screen and only ever
|
||||
// shows a single record, so the row it belongs to is a lookup rather than a
|
||||
// panel sitting in all of them.
|
||||
const openPartsRecord = computed(
|
||||
() => services.value.find((s) => s.id === openParts.value) || null
|
||||
);
|
||||
|
||||
function togglePartsPanel(id, e) {
|
||||
if (openParts.value === id) return closePartsPanel();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
// Kept on screen: opened under the right-hand columns it would otherwise hang
|
||||
// off the edge of the window.
|
||||
partsAt.value = {
|
||||
left: Math.max(8, Math.min(rect.left, window.innerWidth - PARTS_PANEL_WIDTH - 8)),
|
||||
top: rect.bottom + 6,
|
||||
};
|
||||
openParts.value = id;
|
||||
}
|
||||
function closePartsPanel() {
|
||||
openParts.value = "";
|
||||
}
|
||||
function onPartsKey(e) {
|
||||
if (e.key === "Escape") closePartsPanel();
|
||||
}
|
||||
|
||||
// A click anywhere else closes it — the opening click stops before it gets here
|
||||
// — and so does Escape. Scrolling closes it too: the panel is pinned to a point
|
||||
// on the screen, so a page or a table that moves underneath it would leave it
|
||||
// pointing at the wrong row.
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", closePartsPanel);
|
||||
document.addEventListener("keydown", onPartsKey);
|
||||
window.addEventListener("scroll", closePartsPanel, true);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", closePartsPanel);
|
||||
document.removeEventListener("keydown", onPartsKey);
|
||||
window.removeEventListener("scroll", closePartsPanel, true);
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -1093,7 +1150,6 @@ onMounted(load);
|
||||
: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' : '',
|
||||
@@ -1121,6 +1177,17 @@ onMounted(load);
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
|
||||
</template>
|
||||
<template v-else-if="cell.parts">
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 text-left hover:underline"
|
||||
:class="cell.parts.muted ? 'text-muted' : 'text-body'"
|
||||
:aria-expanded="openParts === s.id"
|
||||
@click.stop="togglePartsPanel(s.id, $event)"
|
||||
>
|
||||
{{ cell.parts.text }}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-3.5 w-3.5 shrink-0 text-muted"><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>{{ cell.text }}</template>
|
||||
</td>
|
||||
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
|
||||
@@ -1131,6 +1198,29 @@ onMounted(load);
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- The Changed parts panel. Rendered here rather than inside the cell so
|
||||
one panel serves every row, and pinned to the button it was opened
|
||||
from. -->
|
||||
<div
|
||||
v-if="openPartsRecord"
|
||||
class="dh-card fixed z-30 w-60 p-3 shadow-pop"
|
||||
:style="{ left: partsAt.left + 'px', top: partsAt.top + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<p class="eyebrow mb-2">{{ t("forms.service.changedParts") }}</p>
|
||||
<div
|
||||
v-for="part in SERVICE_PARTS"
|
||||
:key="part.key"
|
||||
class="flex items-center justify-between gap-4 py-0.5 text-sm"
|
||||
>
|
||||
<span class="text-body">{{ t(part.label) }}</span>
|
||||
<span
|
||||
class="text-xs font-semibold"
|
||||
:class="openPartsRecord[part.field] ? 'text-success' : 'text-muted'"
|
||||
>{{ yn(!!openPartsRecord[part.field]) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="canArrangeServiceColumns" class="mt-2 text-xs text-muted">{{ t("car.services.dragHint") }}</p>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user