Files
DriverVault/Web App/web/src/views/Charging.vue
T
tajniak81andClaude Opus 4.8 f9b1bcc520 Web App: fold integration cards; split Charging into two tabs
Settings → Integrations: the Toyota and Anker Solix cards are now
collapsible, like the plugin rows in the API Server panel. Each header
is a toggle with a rotating chevron; the Connected badge stays visible
when collapsed. Bodies use v-show so loaded state and in-flight edits
survive folding. Collapsed by default.

Charging: split into "Public chargers" and "Home chargers" tabs. Public
keeps the discovery map (public pins only) + demo session + nearby
public stations. Home holds the real OCPP control card (moved here) and
the user's home charger list, with a hint pointing to Settings when no
control mode is active. Adds charging.tabs.*, stations.homeHeading and
stations.noControlHint (en/pl/da).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:22:38 +02:00

400 lines
18 KiB
Vue

<script setup>
import { ref, computed, onMounted } from "vue";
import { t } from "../i18n";
import { api } from "../api";
// 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%" },
{ id: "home", name: t("charging.stations.homeCharger"), dist: "—", kw: 11, conn: "NACS", avail: 1, total: 1, price: t("charging.stations.offPeak"), tone: "good", x: "60%", y: "19%", home: true },
];
// Two tabs split the public charging network (discovery map + nearby stations)
// from the user's own home charger(s) and their real OCPP control.
const chargerTab = ref("public"); // "public" | "home"
const publicStations = stations.filter((s) => !s.home);
const homeStations = stations.filter((s) => s.home);
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";
}
}
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 = "";
}
onMounted(async () => {
await loadCtlMode();
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">
<input 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>
<!-- No control mode active: point the user at Settings to enable it. -->
<div v-else class="dh-card p-4">
<p class="text-sm font-semibold text-strong">{{ t("charging.control.title") }}</p>
<p class="mt-2 text-xs text-muted">{{ t("charging.stations.noControlHint") }}</p>
</div>
</div>
<!-- Home station list -->
<div class="flex flex-col gap-4">
<div class="dh-card p-2">
<div class="eyebrow px-3 pb-1.5 pt-2.5">
{{ t("charging.stations.homeHeading") }} · {{ t("charging.stations.count", { n: homeStations.length }) }}
</div>
<button
v-for="s in homeStations"
: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>
</div>
</template>