Removing a home charger kept its own inline prompt, written before there was an app-wide one. Two mechanisms for one question is one too many: it now calls askConfirm() like every other destructive action, and the panel, its pending-removal state and the ask/cancel pair go with it. The button still disables while the delete is in flight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
721 lines
30 KiB
Vue
721 lines
30 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;
|
|
}
|
|
}
|
|
|
|
// 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>
|
|
|
|
<div
|
|
v-for="c in homeChargers"
|
|
:key="c.id"
|
|
class="mt-3 rounded-control bg-sunken p-3"
|
|
:class="selected === c.id ? 'ring-2 ring-accent' : ''"
|
|
>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<p class="truncate text-sm font-semibold text-strong">{{ c.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(c)?.online === true" class="dh-badge dh-badge-success">
|
|
{{ t("charging.info.online") }}
|
|
</span>
|
|
<span v-else-if="liveFor(c)?.online === false" class="dh-badge dh-badge-warning">
|
|
{{ t("charging.info.offline") }}
|
|
</span>
|
|
<span v-if="c.provider" class="dh-badge dh-badge-neutral">
|
|
{{ providerLabel(c.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(c)" :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>
|