Files
DriverVault/Web App/web/src/views/Charging.vue
T
tajniak81andClaude Opus 5 019c28db85 The card is about the charger you picked, not all of them
Charger information stacked every imported charger, so the list beside it
selected one and the card ignored the choice — with two chargers the page
said everything twice and the picking meant nothing. It now shows the
selected one alone, and falls back to the first until something is picked:
a card that stays blank until clicked is a worse greeting than the charger
most people have only one of.

The per-block ring goes with it. Highlighting the selection inside a card
that shows nothing else is a distinction without a second thing to draw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 13:01:09 +02:00

725 lines
31 KiB
Vue

<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { t } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
import { api } from "../api";
import { formatDateTime } from "../lib/format.js";
import ChargerImportModal from "../components/ChargerImportModal.vue";
// Charging & map screen, mirroring the web-dashboard UI kit. There is no live
// charging API yet (only the Anker Solix credential cascade in Settings), so the
// session + stations below are presentational placeholders — swap them for real
// endpoints once the server exposes charging telemetry.
const TONE = {
good: { bg: "var(--success-100)", fg: "var(--success-600)" },
due: { bg: "var(--warning-100)", fg: "var(--warning-600)" },
fault: { bg: "var(--danger-100)", fg: "var(--danger-600)" },
};
const stations = [
{ id: "sc", name: "DriverVault Supercharge", dist: "0.4 km", kw: 250, conn: "CCS · NACS", avail: 6, total: 8, price: "0,34 €", tone: "good", x: "47%", y: "34%" },
{ id: "evgo", name: "EVgo · Market St", dist: "1.2 km", kw: 150, conn: "CCS", avail: 2, total: 6, price: "0,41 €", tone: "due", x: "26%", y: "60%" },
{ id: "cp", name: "ChargePoint Garage", dist: "2.1 km", kw: 62, conn: "J1772", avail: 0, total: 4, price: "0,29 €", tone: "fault", x: "70%", y: "64%" },
];
// Two tabs split the public charging network (discovery map + nearby stations)
// from the user's own chargers and their real OCPP control. The public half is
// still placeholder data; the home half is not — those are records the user
// imported from a service they connected.
const chargerTab = ref("public"); // "public" | "home"
const publicStations = stations;
const selected = ref("sc");
const charging = ref(true);
function stationStatus(s) {
return s.avail === 0
? t("charging.stations.full")
: t("charging.stations.free", { avail: s.avail, total: s.total });
}
const session = {
car: "Model Y",
from: 62,
to: 80,
metrics: [
["rate", "142 kW"],
["added", "+29 km"],
["cost", "5,80 €"],
["done", "~18 min"],
],
};
const sessionMetrics = computed(() =>
session.metrics.map(([key, value]) => ({ label: t(`charging.session.${key}`), value }))
);
// --- Real OCPP control (Anker Solix), gated by the per-user control mode ---
// The demo session card above is presentational; this card drives a real charger
// via the control endpoints when the user has picked Own/Proxy CSMS in Settings.
const ctlMode = ref("off");
const ctlSerial = ref(localStorage.getItem("dv_ctl_serial") || "");
const ctl = ref(null); // { connected, status, controlMode, ... }
const ctlError = ref("");
const ctlBusy = ref(""); // action name currently in flight
const limitAmps = ref(16);
const ctlActive = computed(() => ctlMode.value !== "off");
const ctlConnected = computed(() => !!ctl.value?.connected);
const ctlMeterKwh = computed(() => ((ctl.value?.status?.meterWh || 0) / 1000).toFixed(2));
async function loadCtlMode() {
try {
const v = await api.getAnkerSolix();
ctlMode.value = v?.controlMode || "off";
} catch {
ctlMode.value = "off";
}
}
// The chargers on the linked Anker account. With them the serial is a pick from
// a list; without them (account not linked, or the cloud unreachable) the field
// stays a plain text box so a serial can still be typed in by hand.
const chargers = ref([]);
async function loadChargers() {
try {
const res = await api.listAnkerChargers();
chargers.value = res?.chargers || [];
} catch {
chargers.value = [];
}
// Nothing chosen yet: start on the first charger the account reports.
if (!ctlSerial.value.trim() && chargers.value.length) {
ctlSerial.value = chargers.value[0].sn;
}
}
function chargerLabel(c) {
return c.name ? `${c.name} · ${c.sn}` : c.sn;
}
// --- The user's own chargers, imported from a connected service ---
// The same move the garage makes for a car: a charger on a connected account
// becomes a record here, and stays one after the account is disconnected.
const homeChargers = ref([]);
const homeChargersError = ref("");
const showChargerImport = ref(false);
// The charger services this user could import from. Importing only makes sense
// once one of them is connected, so the button appears only then — same rule the
// garage's import button follows — and the list doubles as the id → label map
// the information card names a charger's origin with.
const chargerProviders = ref([]);
const canImportCharger = computed(() => chargerProviders.value.some((p) => p.connected));
function providerLabel(id) {
return chargerProviders.value.find((p) => p.id === id)?.label || id;
}
async function loadChargerProviders() {
try {
chargerProviders.value = await api.listChargerProviders();
} catch {
chargerProviders.value = [];
}
}
// --- Live reachability, keyed by the provider's own id for the charger ---
// The record says what a charger is; only the service it came from knows
// whether it is reachable right now, so that half is asked for separately and
// held beside the records rather than in them. Asking costs a round trip to
// each connected service, so it happens when the home tab is first opened —
// the moment the question is being asked — and on demand after that.
const chargerLive = ref({}); // provider charger id → the service's own record
const chargerLiveLoading = ref(false);
const chargerLiveLoaded = ref(false);
async function loadChargerLive(force = false) {
const connected = chargerProviders.value.filter((p) => p.connected);
if (chargerLiveLoading.value || connected.length === 0) return;
if (chargerLiveLoaded.value && !force) return; // switching tabs is not a new question
chargerLiveLoading.value = true;
const live = {};
await Promise.all(
connected.map(async (p) => {
try {
const res = await api.listProviderChargers(p.id);
for (const c of res?.chargers || []) live[c.id] = c;
} catch {
// A service that will not answer leaves its chargers unknown rather
// than offline — this page cannot tell those two apart.
}
})
);
chargerLive.value = live;
chargerLiveLoaded.value = true;
chargerLiveLoading.value = false;
}
// The live half for one imported charger, or null when the service it came from
// says nothing about it (disconnected since the import, or a charger added by
// hand). Both services we speak to id a charger by its serial, so the serial is
// a sound fallback for a record imported before the provider link was stored.
function liveFor(c) {
return chargerLive.value[c.providerChargerId] || chargerLive.value[c.serial] || null;
}
// How the charger is registered on the account, in the service's own terms.
function sourcesLabel(sources) {
if (!sources?.length) return "";
return sources.map((src) => {
const key = `charging.info.sourceNames.${src}`;
const label = t(key);
return label === key ? src : label;
}).join(" · ");
}
// The colour the list icon is drawn in: the same green and amber the badges in
// the information card use, so the list can be read at a glance without opening
// anything. A charger the service says nothing about stays muted — unknown is
// not offline, and a green bolt for it would be a claim.
function homeChargerStatusLabel(c) {
const online = liveFor(c)?.online;
if (online === true) return t("charging.info.online");
if (online === false) return t("charging.info.offline");
return "";
}
function homeChargerTone(c) {
const online = liveFor(c)?.online;
if (online === true) return TONE.good.fg;
if (online === false) return TONE.due.fg;
return "var(--text-muted)";
}
// The OCPP connector state as the *service* sees it — the cloud's own reading,
// not our CSMS's. It words it when it can and numbers it when it cannot.
function ocppStatusLabel(live) {
if (live?.ocppStatusDesc) return live.ocppStatusDesc;
return live?.ocppStatus == null ? "" : String(live.ocppStatus);
}
// The cloud's own slug for what the charger is doing (charging, standby, …),
// translated. The vocabulary is the integration's, so the wording lives with it
// in Settings rather than being said twice.
function chargerStateLabel(slug) {
if (!slug) return "";
const key = `settings.integrations.states.${slug}`;
const label = t(key);
return label === key ? slug.replace(/_/g, " ") : label;
}
async function loadHomeChargers() {
homeChargersError.value = "";
try {
homeChargers.value = await api.listHomeChargers();
} catch (e) {
homeChargersError.value = e.message;
}
}
// The card shows one charger: whichever is picked in the list beside it. Until
// something is picked that is the first one — a card that says nothing until
// clicked would be a worse first impression than the charger most people have
// only one of.
const selectedHomeCharger = computed(
() => homeChargers.value.find((c) => c.id === selected.value) || homeChargers.value[0] || null
);
// A charger picked here becomes the one the control card drives.
function selectHomeCharger(c) {
selected.value = c.id;
if (!c.serial) return;
ctlSerial.value = c.serial;
refreshCtl();
}
function onChargerImported(charger) {
showChargerImport.value = false;
homeChargers.value = [...homeChargers.value, charger];
selectHomeCharger(charger);
loadChargerLive(true);
}
// Removing asks first — through the app's own prompt, never window.confirm(),
// which a browser that suppresses dialogs answers with a silent no.
const removing = ref("");
async function removeHomeCharger(c) {
homeChargersError.value = "";
if (!(await askConfirm(t("charging.home.removeConfirm", { name: c.name })))) return;
removing.value = c.id;
try {
await api.deleteHomeCharger(c.id);
homeChargers.value = homeChargers.value.filter((x) => x.id !== c.id);
} catch (e) {
homeChargersError.value = e.message;
} finally {
removing.value = "";
}
}
function homeChargerSubtitle(c) {
return [c.serial, c.model, c.siteName].filter(Boolean).join(" · ");
}
// Everything the card can say about one charger, as label/value rows. Every row
// is drawn every time, a field nothing supplied included: which fields a charger
// has an answer for is itself worth seeing, and a row that comes and goes with
// the data makes two chargers side by side impossible to read against each
// other. Nothing to say is said with a dash.
function chargerInfoRows(c) {
const live = liveFor(c) || {};
const rows = [
["vendor", c.vendor],
["model", c.model],
["firmware", live.firmware],
["serial", c.serial],
["site", c.siteName || live.siteName],
["siteId", live.siteId],
["sources", sourcesLabel(live.sources)],
["power", c.powerKw ? `${c.powerKw} kW` : ""],
["connector", c.connector],
["state", chargerStateLabel(live.status)],
// Relayed as the service words it — the unit is upstream's, so putting one
// on it here would be inventing it.
["chargePower", live.power],
["ocpp", ocppStatusLabel(live)],
["providerId", c.providerChargerId],
["added", c.created ? formatDateTime(c.created) : ""],
];
return rows.map(([key, value]) => ({
key,
label: t(`charging.info.${key}`),
value: value === "" || value == null ? "—" : value,
}));
}
async function refreshCtl() {
const sn = ctlSerial.value.trim();
if (!sn) {
ctl.value = null;
return;
}
localStorage.setItem("dv_ctl_serial", sn);
ctlError.value = "";
try {
ctl.value = await api.getAnkerControl(sn);
} catch (e) {
ctlError.value = e.message;
ctl.value = null;
}
}
async function doAction(action, body) {
const sn = ctlSerial.value.trim();
if (!sn) return;
ctlBusy.value = action;
ctlError.value = "";
try {
await api.ankerControlAction(sn, action, body || {});
await refreshCtl();
} catch (e) {
ctlError.value = e.message;
} finally {
ctlBusy.value = "";
}
}
// Reset reboots the charger — a destructive action the server gates behind an
// explicit confirmation AND a password re-authentication (step-up). Reveal the
// inline password prompt; the actual call happens in confirmReset().
const resetPrompt = ref(false);
const resetPassword = ref("");
function askReset() {
ctlError.value = "";
resetPassword.value = "";
resetPrompt.value = true;
}
async function confirmReset() {
if (!resetPassword.value) return;
resetPrompt.value = false;
await doAction("reset", { hard: false, confirm: true, password: resetPassword.value });
resetPassword.value = "";
}
function cancelReset() {
resetPrompt.value = false;
resetPassword.value = "";
}
// Opening the home tab is the moment reachability is being asked about; the
// public half never needs it.
watch(chargerTab, (tab) => {
if (tab === "home") loadChargerLive();
});
onMounted(async () => {
await loadHomeChargers();
await loadChargerProviders();
if (chargerTab.value === "home") loadChargerLive();
await loadCtlMode();
if (ctlActive.value) await loadChargers();
await refreshCtl();
});
</script>
<template>
<div>
<div class="mb-6">
<p class="eyebrow">{{ t("charging.eyebrow") }}</p>
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("charging.title") }}</h1>
</div>
<!-- Tabs: public network vs. the user's own home charger(s) -->
<div class="mb-6 flex gap-2 border-b border-subtle">
<button
v-for="tab in ['public', 'home']"
:key="tab"
class="-mb-px border-b-2 px-1 pb-3 text-sm font-semibold transition-colors"
:class="chargerTab === tab
? 'border-accent text-strong'
: 'border-transparent text-muted hover:text-body'"
@click="chargerTab = tab"
>
{{ t(`charging.tabs.${tab}`) }}
</button>
</div>
<!-- Public chargers: discovery map + nearby public stations -->
<div v-show="chargerTab === 'public'" class="grid gap-6 lg:grid-cols-[1fr_360px] lg:items-start">
<!-- Map panel -->
<div
class="relative h-[520px] overflow-hidden rounded-card border border-subtle shadow-card"
style="background-color: var(--ink-25);
background-image:
radial-gradient(circle at 32% 28%, rgba(37,99,235,.06), transparent 42%),
repeating-linear-gradient(0deg, transparent 0 44px, rgba(15,30,61,.045) 44px 45px),
repeating-linear-gradient(90deg, transparent 0 44px, rgba(15,30,61,.045) 44px 45px);"
>
<!-- roads -->
<div class="absolute left-0 right-0 top-[52%] h-3.5 bg-brand-100"></div>
<div class="absolute bottom-0 top-0 left-[58%] w-3.5 bg-brand-100"></div>
<div class="absolute left-[18%] top-[-10%] h-[130%] w-2.5 origin-top rotate-[24deg]" style="background: rgba(37,99,235,.10)"></div>
<!-- legend -->
<div class="eyebrow absolute left-4 top-4 flex items-center gap-2 rounded-pill border border-subtle bg-card/90 px-3 py-1.5 backdrop-blur">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-3.5 w-3.5" style="color: var(--brand-600)"><path stroke-linecap="round" stroke-linejoin="round" d="M9 6 3 4v14l6 2 6-2 6 2V6l-6-2-6 2Zm0 0v14m6-16v14"/></svg>
{{ t("charging.liveMap") }}
</div>
<!-- you are here -->
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" :title="t('charging.youAreHere')">
<div class="grid h-10 w-10 place-items-center rounded-full" style="background: rgba(37,99,235,.16)">
<div class="h-3.5 w-3.5 rounded-full border-2 border-white bg-brand-600 shadow-card"></div>
</div>
</div>
<!-- charger pins -->
<button
v-for="s in publicStations"
:key="s.id"
type="button"
class="absolute flex -translate-x-1/2 -translate-y-full flex-col items-center"
:style="{ left: s.x, top: s.y, zIndex: selected === s.id ? 4 : 2 }"
@click="selected = s.id"
>
<span
v-if="selected === s.id"
class="mb-1.5 whitespace-nowrap rounded-control border border-subtle bg-card px-2.5 py-1.5 text-xs font-semibold text-strong shadow-card"
>{{ s.avail }}/{{ s.total }} · {{ s.kw }} kW</span>
<span
class="grid place-items-center rounded-[50%_50%_50%_2px] border-2 border-white"
:class="selected === s.id ? 'h-9 w-9' : 'h-7 w-7'"
:style="{ background: TONE[s.tone].fg, transform: 'rotate(45deg)', boxShadow: 'var(--shadow-sm)' }"
>
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" class="-rotate-45" :class="selected === s.id ? 'h-[18px] w-[18px]' : 'h-3.5 w-3.5'"><path stroke-linecap="round" stroke-linejoin="round" d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></svg>
</span>
</button>
</div>
<!-- Right column: demo session + public station list -->
<div class="flex flex-col gap-4">
<!-- Active session (presentational demo) -->
<div class="relative overflow-hidden rounded-card bg-brand-900 p-5 text-white">
<template v-if="charging">
<div class="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]" style="color: var(--brand-300)">
<span class="h-1.5 w-1.5 rounded-full" style="background: var(--success-600)"></span>
{{ t("charging.session.chargingNow") }} · {{ session.car }}
</div>
<div class="mt-3 font-mono text-4xl font-medium tracking-[-0.03em]">
{{ session.from }}<span class="text-base font-medium text-white/70"> % → {{ session.to }}%</span>
</div>
<div class="mt-3.5 h-2 overflow-hidden rounded-pill bg-white/15">
<div class="h-full rounded-pill bg-brand-400" :style="{ width: session.from + '%' }"></div>
</div>
<div class="mt-4 flex justify-between">
<div v-for="m in sessionMetrics" :key="m.label">
<div class="font-mono text-[9px] uppercase tracking-[0.12em]" style="color: var(--brand-300)">{{ m.label }}</div>
<div class="mt-0.5 font-mono text-[15px] font-medium">{{ m.value }}</div>
</div>
</div>
<button
type="button"
class="mt-4 w-full rounded-control border border-white/20 bg-white/10 px-3 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-white/15"
@click="charging = false"
>{{ t("charging.session.stop") }}</button>
</template>
<template v-else>
<div class="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]" style="color: var(--brand-300)">
<span class="h-1.5 w-1.5 rounded-full bg-white/40"></span>
{{ t("charging.session.idle") }}
</div>
<p class="mt-3 text-sm text-white/70">{{ t("charging.session.idleHint") }}</p>
</template>
</div>
<!-- Station list (public network) -->
<div class="dh-card p-2">
<div class="eyebrow px-3 pb-1.5 pt-2.5">
{{ t("charging.stations.heading") }} · {{ t("charging.stations.count", { n: publicStations.length }) }}
</div>
<button
v-for="s in publicStations"
:key="s.id"
type="button"
class="flex w-full items-center gap-3 rounded-control p-3 text-left transition-colors"
:class="selected === s.id ? 'bg-brand-100' : 'hover:bg-sunken'"
@click="selected = s.id"
>
<div
class="grid h-9 w-9 flex-none place-items-center rounded-control"
:class="selected === s.id ? 'bg-card' : 'bg-sunken'"
>
<svg viewBox="0 0 24 24" fill="none" :stroke="TONE[s.tone].fg" stroke-width="2" class="h-4.5 w-4.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></svg>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold text-strong">{{ s.name }}</div>
<div class="data text-[11px] text-muted">{{ s.dist }} · {{ s.kw }} kW · {{ s.conn }}</div>
</div>
<div class="text-right">
<div class="data text-[11px] font-medium" :style="{ color: TONE[s.tone].fg }">{{ stationStatus(s) }}</div>
<div class="data mt-0.5 text-[11px] text-muted">{{ s.price }}</div>
</div>
</button>
</div>
</div>
</div>
<!-- Home chargers: the user's own charger(s) + real OCPP control -->
<div v-show="chargerTab === 'home'" class="grid gap-6 lg:grid-cols-[1fr_360px] lg:items-start">
<!-- Real OCPP control (Own/Proxy CSMS) — the real home-charger control -->
<div class="flex flex-col gap-4">
<div v-if="ctlActive" class="dh-card p-4">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-strong">{{ t("charging.control.title") }}</p>
<span class="dh-badge" :class="ctlConnected ? 'dh-badge-success' : 'dh-badge-warning'">
{{ ctlConnected ? t("charging.control.connected") : t("charging.control.disconnected") }}
</span>
</div>
<div class="mt-3 flex gap-2">
<select v-if="chargers.length" v-model="ctlSerial" class="dh-input" @change="refreshCtl">
<option v-for="c in chargers" :key="c.sn" :value="c.sn">{{ chargerLabel(c) }}</option>
</select>
<input
v-else
v-model="ctlSerial"
class="dh-input"
:placeholder="t('charging.control.serialPlaceholder')"
autocomplete="off"
/>
<button class="dh-btn dh-btn-ghost shrink-0" @click="refreshCtl">{{ t("charging.control.refresh") }}</button>
</div>
<template v-if="ctlConnected">
<div class="mt-3 grid grid-cols-2 gap-2">
<div class="rounded-control bg-sunken px-3 py-2">
<div class="data text-sm font-semibold text-strong">{{ ctl.status?.connectorStatus || "—" }}</div>
<div class="text-[11px] text-muted">{{ t("charging.control.status") }}</div>
</div>
<div class="rounded-control bg-sunken px-3 py-2">
<div class="data text-sm font-semibold text-strong">{{ ctlMeterKwh }} kWh</div>
<div class="text-[11px] text-muted">{{ t("charging.control.meter") }}</div>
</div>
</div>
<div class="mt-3 flex gap-2">
<button class="dh-btn dh-btn-primary grow" :disabled="ctlBusy === 'start'" @click="doAction('start')">
{{ t("charging.control.start") }}
</button>
<button class="dh-btn dh-btn-ghost grow" :disabled="ctlBusy === 'stop'" @click="doAction('stop')">
{{ t("charging.control.stop") }}
</button>
</div>
<div class="mt-3">
<label class="dh-label flex justify-between">
<span>{{ t("charging.control.limit") }}</span><span class="data text-body">{{ limitAmps }} A</span>
</label>
<input v-model.number="limitAmps" type="range" min="6" max="32" step="1" class="w-full accent-[var(--accent)]" />
<div class="mt-2 flex gap-2">
<button class="dh-btn dh-btn-ghost grow" :disabled="ctlBusy === 'limit'" @click="doAction('limit', { amps: limitAmps })">
{{ t("charging.control.applyLimit") }}
</button>
<button class="dh-btn dh-btn-ghost grow" :disabled="ctlBusy === 'clear-limit'" @click="doAction('clear-limit')">
{{ t("charging.control.clearLimit") }}
</button>
</div>
</div>
<button v-if="!resetPrompt" class="dh-btn dh-btn-ghost mt-3 w-full" :disabled="ctlBusy === 'reset'" @click="askReset">
{{ t("charging.control.reset") }}
</button>
<!-- Step-up: destructive reset requires re-entering the password. -->
<div v-else class="mt-3 rounded-control border border-danger/40 bg-danger-soft p-3">
<p class="text-xs font-medium text-danger">{{ t("charging.control.resetConfirm") }}</p>
<input
v-model="resetPassword"
type="password"
autocomplete="current-password"
class="dh-input mt-2"
:placeholder="t('charging.control.resetPassword')"
@keyup.enter="confirmReset"
/>
<div class="mt-2 flex gap-2">
<button class="dh-btn dh-btn-ghost grow" @click="cancelReset">{{ t("common.cancel") }}</button>
<button class="dh-btn dh-btn-danger grow" :disabled="!resetPassword || ctlBusy === 'reset'" @click="confirmReset">
{{ t("charging.control.reset") }}
</button>
</div>
</div>
</template>
<p v-else class="mt-3 text-xs text-muted">{{ t("charging.control.connectHint") }}</p>
<p v-if="ctlError" class="mt-2 text-sm text-danger">{{ ctlError }}</p>
</div>
<!-- Charger information: everything the record holds about each imported
charger. It stands on its own — control needs Own/Proxy CSMS, but
what the charger *is* is known either way, so with control off this
card is what fills the column instead of a bare hint. -->
<div class="dh-card p-4">
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-semibold text-strong">{{ t("charging.info.title") }}</p>
<button
v-if="homeChargers.length"
type="button"
class="dh-btn dh-btn-ghost !px-2 !py-1 text-xs"
:disabled="chargerLiveLoading"
@click="loadChargerLive(true)"
>
{{ chargerLiveLoading ? t("common.loading") : t("charging.info.refresh") }}
</button>
</div>
<!-- The charger picked in the list beside this card, and only it. -->
<div v-if="selectedHomeCharger" class="mt-3 rounded-control bg-sunken p-3">
<div class="flex items-center justify-between gap-2">
<p class="truncate text-sm font-semibold text-strong">{{ selectedHomeCharger.name }}</p>
<div class="flex shrink-0 items-center gap-2">
<!-- Reachability, said either way. A charger the service says
nothing about stays silent: unknown is not offline. -->
<span v-if="liveFor(selectedHomeCharger)?.online === true" class="dh-badge dh-badge-success">
{{ t("charging.info.online") }}
</span>
<span v-else-if="liveFor(selectedHomeCharger)?.online === false" class="dh-badge dh-badge-warning">
{{ t("charging.info.offline") }}
</span>
<span v-if="selectedHomeCharger.provider" class="dh-badge dh-badge-neutral">
{{ providerLabel(selectedHomeCharger.provider) }}
</span>
</div>
</div>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in chargerInfoRows(selectedHomeCharger)" :key="row.key">
<dt class="text-[11px] text-muted">{{ row.label }}</dt>
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
</template>
</dl>
</div>
<p v-if="homeChargers.length === 0" class="mt-2 text-xs text-muted">
{{ t("charging.info.empty") }}
</p>
<!-- Control mode off: say why the card above is missing, here, where
there is now something to read it against. -->
<p v-if="!ctlActive" class="mt-3 text-xs text-muted">{{ t("charging.stations.noControlHint") }}</p>
</div>
</div>
<!-- The user's own chargers -->
<div class="flex flex-col gap-4">
<div class="dh-card p-2">
<div class="flex items-center justify-between gap-2 px-3 pb-1.5 pt-2.5">
<span class="eyebrow">
{{ t("charging.stations.homeHeading") }} · {{ t("charging.home.count", { n: homeChargers.length }) }}
</span>
<button
v-if="canImportCharger"
type="button"
class="dh-btn dh-btn-ghost !px-2 !py-1 text-xs"
@click="showChargerImport = true"
>
{{ t("charging.home.import") }}
</button>
</div>
<div
v-for="c in homeChargers"
:key="c.id"
class="flex w-full items-center gap-3 rounded-control p-3 text-left transition-colors"
:class="selected === c.id ? 'bg-brand-100' : 'hover:bg-sunken'"
>
<button type="button" class="flex min-w-0 flex-1 items-center gap-3 text-left" @click="selectHomeCharger(c)">
<span
class="grid h-9 w-9 flex-none place-items-center rounded-control"
:class="selected === c.id ? 'bg-card' : 'bg-sunken'"
:title="homeChargerStatusLabel(c)"
>
<svg viewBox="0 0 24 24" fill="none" :stroke="homeChargerTone(c)" stroke-width="2" class="h-4.5 w-4.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></svg>
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-strong">{{ c.name }}</span>
<span class="data block truncate text-[11px] text-muted">{{ homeChargerSubtitle(c) }}</span>
</span>
</button>
<button
type="button"
class="shrink-0 text-xs font-medium text-muted hover:text-danger disabled:opacity-50"
:title="t('charging.home.remove')"
:disabled="removing === c.id"
@click="removeHomeCharger(c)"
>
{{ t("charging.home.remove") }}
</button>
</div>
<!-- Nothing imported yet: say what this list is for and offer the import. -->
<div v-if="homeChargers.length === 0" class="px-3 pb-3 pt-1">
<p class="text-sm text-muted">{{ t("charging.home.empty") }}</p>
<button
v-if="canImportCharger"
type="button"
class="dh-btn dh-btn-primary mt-3 w-full"
@click="showChargerImport = true"
>
{{ t("charging.home.import") }}
</button>
<p v-else class="mt-2 text-xs text-muted">{{ t("charging.home.connectFirst") }}</p>
</div>
<p v-if="homeChargersError" class="px-3 pb-3 text-sm text-danger">{{ homeChargersError }}</p>
</div>
</div>
</div>
<ChargerImportModal
v-if="showChargerImport"
@saved="onChargerImported"
@close="showChargerImport = false"
/>
</div>
</template>