專案

一般

配置概況

Feature #1422 » backend-admin-dashboard-mock-v0.3.html

Backend Admin Dashboard v0.3 Draft review bundle - 陳國瑋, 2026-09-07 05:08

 
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="document-version" content="v0.3 Draft" />
<meta name="document-date" content="2026-09-07" />
<meta name="document-scope" content="全案場適用;A17 僅為展示案例" />
<title>CloudLink EMS|Backend Admin 營運 Dashboard Mockup|v0.3 Draft</title>
<style>
:root {
--ink: #10252b;
--muted: #6f8185;
--line: #dfe8e8;
--paper: #ffffff;
--canvas: #f3f7f6;
--nav: #0b2026;
--nav-soft: #17343b;
--teal: #11a891;
--teal-soft: #ddf6f0;
--cyan: #3f92a6;
--blue-soft: #e6f1f6;
--green: #2b956a;
--green-soft: #e4f5ec;
--amber: #d48a19;
--amber-soft: #fff2dc;
--red: #d14d4a;
--red-soft: #fdebea;
--shadow: 0 14px 34px rgba(17, 55, 61, 0.07);
--radius: 18px;
}

* {
box-sizing: border-box;
}

html {
scroll-behavior: smooth;
}

body {
margin: 0;
background: var(--canvas);
color: var(--ink);
font-family:
Inter, "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
}

button {
font: inherit;
}

.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 238px minmax(0, 1fr);
}

.sidebar {
position: sticky;
top: 0;
height: 100vh;
padding: 28px 18px 22px;
color: #d7e7e7;
background:
radial-gradient(circle at 12% 88%, rgba(17, 168, 145, 0.23), transparent 34%),
var(--nav);
display: flex;
flex-direction: column;
z-index: 10;
}

.brand {
display: flex;
align-items: center;
gap: 11px;
padding: 0 10px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}

.brand-mark {
position: relative;
width: 36px;
height: 36px;
border-radius: 11px;
background: linear-gradient(145deg, #62e2ca, var(--teal));
box-shadow: 0 8px 24px rgba(17, 168, 145, 0.28);
}

.brand-mark::before,
.brand-mark::after {
content: "";
position: absolute;
background: var(--nav);
border-radius: 4px;
}

.brand-mark::before {
width: 7px;
height: 21px;
left: 15px;
top: 7px;
transform: skew(-17deg);
}

.brand-mark::after {
width: 17px;
height: 6px;
left: 9px;
top: 15px;
transform: skew(-17deg);
}

.brand strong {
display: block;
color: #fff;
font-size: 15px;
letter-spacing: 0.03em;
}

.brand span {
display: block;
color: #84a2a7;
font-size: 11px;
}

.site-identity {
margin: 18px 7px 20px;
padding: 12px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 13px;
}

.site-identity small,
.site-identity span {
display: block;
color: #78979d;
}

.site-identity small {
margin-bottom: 4px;
}

.site-identity strong {
display: block;
color: #fff;
font-size: 13px;
}

.site-identity span {
margin-top: 3px;
font-size: 10px;
}

.site-identity.is-error {
background: rgba(209, 77, 74, 0.12);
border-color: rgba(244, 150, 146, 0.28);
}

.site-identity.is-error strong {
color: #ffd3d1;
}

.nav-label {
padding: 0 12px;
margin: 5px 0 8px;
color: #5e7c82;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.13em;
}

.nav {
display: grid;
gap: 4px;
}

.nav a {
display: flex;
align-items: center;
gap: 11px;
min-height: 42px;
padding: 0 12px;
color: #a9c0c4;
text-decoration: none;
border-radius: 11px;
transition: 160ms ease;
}

.nav a:hover,
.nav a.active {
color: #fff;
background: var(--nav-soft);
}

.nav a.active {
box-shadow: inset 3px 0 0 #4de0c4;
}

.nav-icon {
width: 21px;
text-align: center;
color: #76bcb0;
font-size: 15px;
}

.nav-count {
margin-left: auto;
min-width: 22px;
padding: 2px 6px;
color: #ffd7d5;
background: rgba(209, 77, 74, 0.28);
border-radius: 999px;
font-size: 10px;
text-align: center;
}

.sidebar-foot {
margin-top: auto;
padding: 14px 12px 0;
color: #69888e;
border-top: 1px solid rgba(255, 255, 255, 0.08);
font-size: 11px;
}

main {
min-width: 0;
padding: 28px 32px 44px;
}

.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-bottom: 22px;
}

.title-block {
display: flex;
align-items: flex-start;
gap: 13px;
}

.title-block h1 {
margin: 0 0 4px;
font-size: clamp(23px, 2vw, 30px);
line-height: 1.2;
letter-spacing: -0.03em;
}

.title-block p {
margin: 0;
color: var(--muted);
}

.page-site-name {
color: var(--ink);
font-weight: 750;
}

.demo-badge {
flex: none;
margin-top: 2px;
padding: 5px 9px;
color: #8c5f12;
background: var(--amber-soft);
border: 1px solid #f4d69f;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
}

.toolbar {
display: flex;
align-items: center;
gap: 10px;
}

.freshness {
color: var(--muted);
font-size: 12px;
text-align: right;
}

.freshness strong {
display: block;
color: var(--green);
font-size: 12px;
}

.freshness.is-error strong {
color: var(--red);
}

.freshness.is-warning strong {
color: var(--amber);
}

.refresh-btn {
width: 38px;
height: 38px;
border: 1px solid var(--line);
border-radius: 11px;
color: var(--ink);
background: var(--paper);
cursor: pointer;
box-shadow: 0 5px 15px rgba(17, 55, 61, 0.05);
}

.refresh-btn.spin {
animation: spin 480ms ease;
}

.refresh-btn:disabled {
cursor: not-allowed;
opacity: 0.48;
}

.site-config-error {
display: none;
grid-template-columns: auto minmax(0, 1fr);
gap: 16px;
align-items: flex-start;
padding: 24px;
background: var(--paper);
border: 1px solid #f2c5c3;
border-left: 4px solid var(--red);
border-radius: var(--radius);
box-shadow: var(--shadow);
}

.site-config-error-icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
color: var(--red);
background: var(--red-soft);
border-radius: 11px;
font-size: 20px;
font-weight: 850;
}

.site-config-error .eyebrow {
color: var(--red);
}

.site-config-error h2 {
margin: 3px 0 5px;
font-size: 20px;
}

.site-config-error p {
max-width: 720px;
margin: 0;
color: var(--muted);
}

main.has-site-config-error .site-config-error {
display: grid;
}

main.has-site-config-error > .attention,
main.has-site-config-error > .stats-grid,
main.has-site-config-error > .dashboard-grid,
main.has-site-config-error > .connector-panel,
main.has-site-config-error > .bottom-grid,
main.has-site-config-error > .footnote {
display: none;
}

@keyframes spin {
to {
transform: rotate(360deg);
}
}

.attention {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 1px;
margin-bottom: 18px;
overflow: hidden;
border: 1px solid #f0d8d7;
border-radius: var(--radius);
background: #efd9d8;
box-shadow: var(--shadow);
}

.attention.is-empty {
border-color: #cfe8df;
background: #cfe8df;
}

.attention.is-partial {
border-color: #ead9b9;
background: #ead9b9;
}

.attention.is-error {
border-color: #edcfcc;
background: #edcfcc;
}

.attention-intro,
.attention-item {
min-height: 92px;
padding: 18px 19px;
background: var(--paper);
}

.attention-intro {
grid-column: span 2;
background: linear-gradient(135deg, #fff3f2, #fffafa);
}

.eyebrow {
display: flex;
align-items: center;
gap: 8px;
color: var(--red);
font-size: 10px;
font-weight: 900;
letter-spacing: 0.12em;
text-transform: uppercase;
}

.pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--red);
box-shadow: 0 0 0 5px rgba(209, 77, 74, 0.12);
}

.attention-intro h2 {
margin: 8px 0 0;
font-size: 18px;
}

.attention-item {
display: grid;
grid-template-columns: auto 1fr;
align-content: center;
gap: 3px 11px;
color: inherit;
text-decoration: none;
transition: background 160ms ease, box-shadow 160ms ease;
}

.attention-item:hover {
background: #fffafa;
}

.attention-item:focus-visible {
position: relative;
z-index: 1;
outline: 3px solid rgba(17, 168, 145, 0.34);
outline-offset: -3px;
}

.attention-item .number {
grid-row: 1 / 4;
align-self: center;
font-size: 30px;
font-weight: 800;
line-height: 1;
text-align: center;
}

.attention-item .number small {
display: block;
margin-top: 5px;
color: var(--muted);
font-size: 8px;
font-weight: 750;
letter-spacing: 0.01em;
white-space: nowrap;
}

.attention-item strong {
font-size: 12px;
}

.attention-item span {
color: var(--muted);
font-size: 11px;
}

.attention-item .attention-action {
color: #087764;
font-size: 9px;
font-weight: 800;
}

.attention-target {
scroll-margin-top: 18px;
}

.attention-target:focus-visible {
outline: 3px solid #087764;
outline-offset: 4px;
}

.attention-empty {
grid-column: 1 / -1;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 5px 13px;
min-height: 72px;
padding: 15px 19px;
background: linear-gradient(135deg, #edf9f5, #f8fcfa);
}

.attention-empty-icon {
grid-row: 1 / 3;
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 50%;
color: #087764;
background: #d7f1e9;
font-size: 18px;
font-weight: 900;
}

.attention-empty strong {
color: #075e52;
font-size: 14px;
}

.attention-empty-description,
.attention-empty-time {
color: var(--muted);
font-size: 10px;
}

.attention-empty-time {
grid-column: 3;
grid-row: 1 / 3;
white-space: nowrap;
}

.attention-partial,
.attention-error {
grid-column: 1 / -1;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 4px 12px;
min-height: 62px;
padding: 12px 19px;
}

.attention-partial {
grid-template-columns: auto minmax(0, 1fr);
background: linear-gradient(135deg, #fff8e9, #fffdf7);
}

.attention-error {
min-height: 72px;
background: linear-gradient(135deg, #fff2f1, #fffafa);
}

.attention-state-icon {
grid-row: 1 / 3;
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 50%;
font-size: 15px;
font-weight: 900;
}

.attention-partial .attention-state-icon {
color: #8a5700;
background: #f6e6bd;
}

.attention-error .attention-state-icon {
color: #a93634;
background: #f4d9d7;
}

.attention-partial strong,
.attention-error strong {
font-size: 13px;
}

.attention-partial strong {
color: #704800;
}

.attention-error strong {
color: #953432;
}

.attention-state-description,
.attention-state-time {
color: var(--muted);
font-size: 10px;
}

.attention-state-description {
grid-column: 2;
grid-row: 2;
}

.attention-state-time {
grid-column: 3;
grid-row: 1 / 3;
white-space: nowrap;
}

.stats-grid {
display: grid;
grid-template-columns: repeat(6, minmax(135px, 1fr));
align-items: stretch;
gap: 12px;
margin-bottom: 18px;
}

.stat-card,
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--paper);
box-shadow: var(--shadow);
}

.stat-card {
position: relative;
min-width: 0;
min-height: 126px;
padding: 17px;
overflow: hidden;
}

.stat-link {
display: flex;
flex-direction: column;
color: inherit;
text-decoration: none;
touch-action: manipulation;
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}

.stat-link:hover {
border-color: rgba(17, 168, 145, 0.42);
box-shadow: 0 12px 28px rgba(14, 47, 53, 0.12);
transform: translateY(-1px);
}

.stat-link:focus-visible {
outline: 3px solid #087764;
outline-offset: 3px;
}

.stat-card::after {
content: "";
position: absolute;
width: 72px;
height: 72px;
right: -27px;
top: -25px;
border-radius: 50%;
background: var(--tone, var(--teal-soft));
}

.stat-label {
color: var(--muted);
font-size: 11px;
font-weight: 700;
overflow-wrap: anywhere;
}

.stat-value {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 5px;
min-width: 0;
margin: 11px 0 6px;
font-size: 27px;
font-weight: 820;
letter-spacing: -0.03em;
}

.stat-value small {
color: var(--muted);
font-size: 12px;
font-weight: 650;
}

.stat-meta {
margin-top: auto;
color: var(--muted);
font-size: 10px;
overflow-wrap: anywhere;
}

.stat-meta.good {
color: var(--green);
}

.stat-meta.warn {
color: var(--red);
}

.stat-card.kpi-state-partial,
.stat-card.kpi-state-refresh-error {
border-color: #e4c98f;
background: linear-gradient(145deg, #fffdf8, var(--paper));
}

.stat-card.kpi-state-error {
border-color: #e6bab7;
background: linear-gradient(145deg, #fff9f8, var(--paper));
}

.stat-card.kpi-state-stale {
border-color: #cbd5d6;
background: linear-gradient(145deg, #f7f9f9, var(--paper));
}

.stat-card.kpi-state-partial .stat-meta,
.stat-card.kpi-state-refresh-error .stat-meta {
color: #8a5700;
font-weight: 750;
}

.stat-card.kpi-state-error .stat-meta {
color: var(--red);
font-weight: 750;
}

.stat-card.kpi-state-stale .stat-meta {
color: #53686d;
font-weight: 750;
}

.kpi-skeleton {
display: block;
border-radius: 5px;
background: #e5ecec;
}

.kpi-skeleton-value {
width: 78px;
height: 28px;
}

.kpi-skeleton-meta {
width: 112px;
height: 10px;
}

.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(330px, 0.85fr);
gap: 18px;
margin-bottom: 18px;
}

.panel {
min-width: 0;
padding: 20px;
}

.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 17px;
}

.panel-head h2 {
margin: 0 0 3px;
font-size: 16px;
}

.panel-head p {
margin: 0;
color: var(--muted);
font-size: 11px;
}

.live-connection-alert {
display: none;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
margin: -3px 0 16px;
padding: 11px 12px;
color: #6f4b0e;
background: var(--amber-soft);
border: 1px solid #efd3a2;
border-radius: 11px;
}

.live-connection-alert-icon {
display: grid;
width: 22px;
height: 22px;
place-items: center;
border-radius: 50%;
background: rgba(212, 138, 25, 0.14);
font-size: 12px;
font-weight: 850;
}

.live-connection-alert strong,
.live-connection-alert span {
display: block;
}

.live-connection-alert strong {
font-size: 11px;
}

.live-connection-alert span {
margin-top: 1px;
font-size: 10px;
}

.panel.is-live-disconnected .live-connection-alert {
display: grid;
}

#load.is-live-disconnected .load-summary,
#load.is-live-disconnected .load-axis-guide,
#load.is-live-disconnected .chart-wrap,
#connectors.is-live-disconnected .connector-status-summary,
#connectors.is-live-disconnected .connector-grid,
#connectors.is-live-disconnected .connector-preview-footer,
#connectors.is-live-disconnected .detail-drawer {
opacity: 0.56;
}

.segmented {
display: inline-flex;
padding: 3px;
border: 1px solid var(--line);
border-radius: 10px;
background: #f7f9f9;
}

.segmented button {
min-width: 42px;
padding: 5px 9px;
border: 0;
border-radius: 7px;
color: var(--muted);
background: transparent;
font-size: 10px;
cursor: pointer;
}

.segmented button.active {
color: #fff;
background: var(--nav-soft);
box-shadow: 0 4px 11px rgba(17, 55, 61, 0.12);
}

.load-summary {
display: grid;
grid-template-columns: auto 1fr;
align-items: end;
gap: 18px;
margin-bottom: 10px;
}

.load-number {
font-size: 34px;
font-weight: 820;
letter-spacing: -0.04em;
}

.load-number-label {
display: block;
margin-bottom: 2px;
color: var(--muted);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
}

.load-number small {
margin-left: 5px;
color: var(--muted);
font-size: 12px;
font-weight: 600;
}

.load-context {
padding-bottom: 5px;
color: var(--muted);
font-size: 11px;
}

.load-context strong {
color: var(--teal);
}

.load-axis-guide {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 5px 14px;
margin: 2px 0 8px;
color: var(--muted);
font-size: 9px;
}

.load-axis-guide strong {
margin-right: 4px;
color: var(--ink);
font-weight: 800;
}

.chart-wrap {
position: relative;
height: 180px;
overflow: hidden;
border: 1px solid #edf2f2;
border-radius: 13px;
background: #fbfdfd;
}

.chart-wrap.has-chart-state canvas {
visibility: hidden;
}

.load-chart-state {
position: absolute;
inset: 0;
display: grid;
align-content: center;
justify-items: center;
gap: 6px;
padding: 24px;
text-align: center;
}

.load-chart-state[hidden] {
display: none;
}

.load-chart-state-icon {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 50%;
color: #087764;
background: #dff4ee;
font-size: 17px;
font-weight: 900;
}

.load-chart-state.is-error .load-chart-state-icon {
color: #a0443b;
background: #f8e4e1;
}

.load-chart-state strong {
color: var(--ink);
font-size: 13px;
}

.load-chart-state span:last-child {
max-width: 420px;
color: var(--muted);
font-size: 10px;
line-height: 1.5;
}

canvas {
display: block;
width: 100%;
height: 100%;
}

.groups {
display: grid;
gap: 15px;
}

.group-row {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
gap: 11px;
align-items: center;
}

.group-code {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 12px;
color: var(--nav);
background: var(--teal-soft);
font-size: 12px;
font-weight: 850;
}

.group-info strong,
.group-metric strong {
font-size: 12px;
}

.group-info span,
.group-metric span {
display: block;
color: var(--muted);
font-size: 10px;
}

.meter {
height: 7px;
margin-top: 7px;
border-radius: 99px;
background: #edf2f2;
overflow: hidden;
}

.meter > span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--teal), #55d7be);
}

.group-metric {
text-align: right;
}

.slots-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--line);
}

.slots-summary div {
padding: 10px;
border-radius: 11px;
background: #f7f9f9;
text-align: center;
}

.slots-summary strong {
display: block;
font-size: 17px;
}

.slots-summary span {
color: var(--muted);
font-size: 9px;
}

.connector-panel {
margin-bottom: 18px;
}

.connector-head-tools {
display: grid;
justify-items: end;
gap: 8px;
}

.connector-head-meta {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 10px;
}

.connector-head-meta strong {
color: var(--ink);
font-size: 11px;
}

.connector-freshness {
padding: 3px 7px;
border-radius: 999px;
color: #18714f;
background: var(--green-soft);
font-size: 9px;
font-weight: 820;
letter-spacing: 0.04em;
}

.connector-status-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px;
margin-bottom: 14px;
}

.connector-status-summary[hidden] {
display: none;
}

.connector-summary-item {
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--line);
border-left: 3px solid #9aa9ac;
border-radius: 11px;
background: #fafcfc;
cursor: default;
}

.connector-summary-item span,
.connector-summary-item small {
color: var(--muted);
font-size: 9px;
}

.connector-summary-item span {
display: block;
font-weight: 760;
}

.connector-summary-item strong {
margin-right: 3px;
font-size: 20px;
line-height: 1.25;
}

.connector-summary-item[data-bucket="NEEDS_ATTENTION"] {
border-left-color: var(--red);
background: #fffafa;
}

.connector-summary-item[data-bucket="NEEDS_ATTENTION"] strong {
color: var(--red);
}

.connector-summary-item[data-bucket="CHARGING"] {
border-left-color: var(--teal);
}

.connector-summary-item[data-bucket="CHARGING"] strong {
color: #087764;
}

.connector-summary-item[data-bucket="WAITING"] {
border-left-color: var(--amber);
background: #fffcf5;
}

.connector-summary-item[data-bucket="WAITING"] strong {
color: #9a620c;
}

.connector-summary-item[data-bucket="AVAILABLE"] {
border-left-color: var(--green);
}

.connector-summary-item[data-bucket="AVAILABLE"] strong {
color: var(--green);
}

.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
color: var(--muted);
font-size: 10px;
}

.legend span {
display: flex;
align-items: center;
gap: 5px;
}

.legend i,
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}

.connector-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}

.connector-empty {
padding: 34px 18px;
border: 1px dashed #cbd9d9;
border-radius: 14px;
color: var(--muted);
background: #fafcfc;
text-align: center;
}

.connector-empty strong,
.connector-empty span {
display: block;
}

.connector-empty strong {
color: var(--ink);
font-size: 14px;
}

.connector-empty span {
margin: 3px 0 12px;
font-size: 10px;
}

.connector-empty a {
color: var(--teal);
font-size: 10px;
font-weight: 780;
text-decoration: none;
}

.connector {
position: relative;
min-height: 112px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: #fbfcfc;
cursor: pointer;
transition: 160ms ease;
}

.connector[hidden],
.connector-list-row[hidden],
.connector-grid[hidden],
.connector-empty[hidden],
.connector-preview-footer[hidden],
.view-all-connectors[hidden] {
display: none;
}

.connector:hover,
.connector:focus-visible {
outline: none;
border-color: #8fcfc4;
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(17, 55, 61, 0.08);
}

.connector.problem {
border-color: #efc4c2;
background: #fffafa;
}

.connector.warning {
border-color: #ecd19f;
background: #fffaf1;
}

.connector-top {
display: flex;
align-items: center;
justify-content: space-between;
}

.connector-id {
font-size: 11px;
font-weight: 820;
}

.connector-status {
display: flex;
align-items: center;
gap: 5px;
font-size: 9px;
font-weight: 800;
}

.connector-power {
margin: 15px 0 9px;
font-size: 20px;
font-weight: 820;
}

.connector-power small {
color: var(--muted);
font-size: 10px;
}

.connector-meta {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--muted);
font-size: 9px;
}

.queue-pill {
padding: 2px 6px;
border-radius: 999px;
color: #7f5a17;
background: var(--amber-soft);
}

.connector-preview-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--line);
}

.connector-preview-count {
color: var(--muted);
font-size: 10px;
}

.connector-preview-count strong {
color: var(--ink);
font-size: 11px;
}

.connector-hidden-abnormal {
display: block;
margin-top: 2px;
color: var(--red);
font-weight: 780;
}

.view-all-connectors {
min-height: 38px;
padding: 8px 13px;
border: 0;
border-radius: 10px;
color: #fff;
background: var(--nav-soft);
font-size: 11px;
font-weight: 780;
cursor: pointer;
}

.view-all-connectors:hover,
.view-all-connectors:focus-visible {
outline: 3px solid rgba(17, 168, 145, 0.22);
background: var(--nav);
}

.detail-drawer {
display: none;
margin-top: 12px;
padding: 14px 16px;
border-radius: 13px;
color: #d4e5e5;
background: var(--nav-soft);
}

.detail-drawer.open {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}

.detail-drawer strong {
color: #fff;
}

.detail-drawer span {
color: #9db6ba;
font-size: 11px;
}

.detail-drawer button {
padding: 7px 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 9px;
color: #fff;
background: rgba(255, 255, 255, 0.08);
cursor: pointer;
}

.connector-list-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
justify-items: end;
background: rgba(6, 24, 29, 0.52);
backdrop-filter: blur(2px);
}

.connector-list-backdrop[hidden] {
display: none;
}

.connector-list-drawer {
width: min(76vw, 960px);
min-width: min(720px, 100vw);
height: 100%;
padding: 24px;
overflow: auto;
color: var(--ink);
background: var(--paper);
box-shadow: -18px 0 42px rgba(6, 24, 29, 0.2);
}

.connector-list-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding-bottom: 16px;
border-bottom: 1px solid var(--line);
}

.connector-list-head h2 {
margin: 2px 0 3px;
font-size: 20px;
}

.connector-list-head p {
margin: 0;
color: var(--muted);
font-size: 11px;
}

.drawer-close {
width: 38px;
height: 38px;
border: 1px solid var(--line);
border-radius: 10px;
color: var(--ink);
background: #f7f9f9;
font-size: 18px;
cursor: pointer;
}

.drawer-close:focus-visible {
outline: 3px solid rgba(17, 168, 145, 0.22);
}

.connector-list-controls {
display: grid;
grid-template-columns: minmax(220px, 1fr) 180px 180px;
gap: 10px;
margin: 18px 0 10px;
}

.connector-list-controls label {
display: grid;
gap: 4px;
color: var(--muted);
font-size: 9px;
font-weight: 760;
}

.connector-list-controls input,
.connector-list-controls select {
min-height: 40px;
width: 100%;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 10px;
color: var(--ink);
background: #fff;
font: inherit;
font-size: 11px;
}

.connector-list-controls input:focus,
.connector-list-controls select:focus {
outline: 3px solid rgba(17, 168, 145, 0.16);
border-color: #75bfb2;
}

.connector-list-note {
margin-bottom: 12px;
color: var(--muted);
font-size: 9px;
}

.connector-list {
display: grid;
gap: 7px;
}

.connector-list-row {
display: grid;
grid-template-columns: 84px minmax(110px, 1fr) minmax(110px, 1fr) minmax(120px, 1.2fr) auto;
align-items: center;
gap: 12px;
min-height: 50px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 11px;
background: #fbfcfc;
font-size: 10px;
}

.connector-list-row.is-abnormal {
border-color: #ecd1cf;
background: #fffafa;
}

.connector-list-row strong {
font-size: 11px;
}

.connector-list-row span {
color: var(--muted);
}

.connector-list-row .row-state {
color: var(--ink);
font-weight: 760;
}

.connector-list-row .row-detail {
justify-self: end;
color: var(--teal);
text-decoration: none;
font-weight: 780;
}

.connector-list-empty {
padding: 36px 16px;
color: var(--muted);
text-align: center;
}

.connector-list-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 10px;
}

.connector-list-footer a {
color: var(--teal);
font-weight: 780;
text-decoration: none;
}

.connector-list-pagination {
display: flex;
align-items: center;
gap: 8px;
}

.connector-list-pagination[hidden] {
display: none;
}

.connector-list-pagination button {
min-height: 34px;
padding: 7px 11px;
border: 1px solid var(--line);
border-radius: 9px;
background: #fff;
color: var(--ink);
font: inherit;
font-size: 10px;
font-weight: 760;
cursor: pointer;
}

.connector-list-pagination button:hover:not(:disabled) {
border-color: #75bfb2;
color: var(--teal);
}

.connector-list-pagination button:focus-visible {
outline: 3px solid rgba(17, 168, 145, 0.18);
}

.connector-list-pagination button:disabled {
cursor: not-allowed;
opacity: 0.45;
}

body.has-open-drawer {
overflow: hidden;
}

.bottom-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.7fr);
gap: 18px;
}

.usage-layout {
display: grid;
grid-template-columns: 1fr 165px;
gap: 18px;
}

.usage-chart {
height: 170px;
}

.usage-facts {
display: grid;
align-content: center;
gap: 11px;
}

.fact {
padding: 11px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fafcfc;
}

.fact span {
display: block;
color: var(--muted);
font-size: 9px;
}

.fact strong {
font-size: 16px;
}

.pipeline {
display: grid;
gap: 10px;
}

.pipeline-row {
display: grid;
grid-template-columns: 34px 1fr auto;
gap: 11px;
align-items: center;
padding: 9px 0;
border-bottom: 1px solid var(--line);
}

.pipeline-row:last-child {
border-bottom: 0;
}

.pipeline-index {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--green);
background: var(--green-soft);
font-size: 10px;
font-weight: 850;
}

.pipeline-index.pending {
color: var(--amber);
background: var(--amber-soft);
}

.pipeline-info strong {
display: block;
font-size: 11px;
}

.pipeline-info span {
color: var(--muted);
font-size: 9px;
}

.pipeline-value {
font-size: 12px;
font-weight: 800;
}

.footnote {
margin: 17px 2px 0;
color: #819094;
font-size: 10px;
text-align: right;
}

@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}

.stat-link {
transition: none;
}

.stat-link:hover {
transform: none;
}
}

@media (max-width: 1239px) {
.stats-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}

.connector-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}

.connector-grid .connector:nth-child(n + 7) {
display: none;
}
}

@media (max-width: 940px) {
.app-shell {
grid-template-columns: 1fr;
}

.sidebar {
position: static;
height: auto;
padding: 16px;
}

.brand {
padding-bottom: 14px;
}

.site-identity,
.nav-label,
.sidebar-foot {
display: none;
}

.nav {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 12px;
}

.nav a {
justify-content: center;
font-size: 11px;
}

.nav a:nth-child(n + 5) {
display: none;
}

main {
padding: 22px 18px 36px;
}

.attention {
grid-template-columns: 1fr 1fr;
}

.attention-intro {
grid-column: 1 / -1;
}

.attention-empty,
.attention-error {
grid-template-columns: auto minmax(0, 1fr);
}

.attention-empty-time,
.attention-error .attention-state-time {
grid-column: 2;
grid-row: auto;
}

.dashboard-grid,
.bottom-grid {
grid-template-columns: 1fr;
}

.connector-list-drawer {
width: 100%;
min-width: 0;
}
}

@media (max-width: 767px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}

.stat-value {
font-size: clamp(24px, 6vw, 27px);
}

.connector-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}

.connector-list-controls {
grid-template-columns: 1fr;
}

.connector-list-row {
grid-template-columns: 74px minmax(100px, 1fr) minmax(110px, 1fr);
}

.connector-list-row .row-area {
display: none;
}
}

@media (max-width: 639px) {
.topbar,
.panel-head {
align-items: flex-start;
flex-direction: column;
}

.toolbar {
align-self: stretch;
justify-content: space-between;
}

.attention {
grid-template-columns: 1fr;
}

.attention-intro {
grid-column: 1 / -1;
}

.connector-grid {
grid-template-columns: 1fr;
}

.connector-grid .connector:nth-child(n + 5) {
display: none;
}

.connector-head-tools {
justify-items: start;
width: 100%;
}

.connector-head-meta,
.connector-preview-footer,
.connector-list-footer {
align-items: flex-start;
flex-direction: column;
}

.view-all-connectors {
width: 100%;
}

.connector-list-drawer {
padding: 18px;
}

.connector-list-row {
grid-template-columns: 1fr auto;
}

.connector-list-row .row-cp,
.connector-list-row .row-area {
display: none;
}

.connector-list-row .row-state {
grid-column: 1;
}

.connector-list-row .row-detail {
grid-column: 2;
grid-row: 1 / span 2;
}
}

.usage-layout {
grid-template-columns: 1fr;
}

.usage-facts {
grid-template-columns: repeat(3, 1fr);
}

.load-summary {
grid-template-columns: 1fr;
gap: 5px;
}

.load-axis-guide {
display: grid;
}
}
</style>
</head>
<body>
<div class="app-shell">
<aside class="sidebar" aria-label="主要導覽">
<div class="brand">
<div class="brand-mark" aria-hidden="true"></div>
<div>
<strong>CloudLink EMS</strong>
<span>Energy operations</span>
</div>
</div>

<!-- Site scope contract: this is the one enabled Building resolved by the current EMS Branch. It is identification, not a site selector. -->
<div class="site-identity" id="siteIdentity" aria-label="目前案場:寶台 A17・地下停車場">
<small>目前案場</small>
<strong id="sidebarSiteName">寶台 A17・地下停車場</strong>
<span>本 Branch 的啟用案場</span>
</div>

<div class="nav-label">OPERATIONS</div>
<nav class="nav">
<a class="active" href="#overview"><span class="nav-icon">▦</span>營運總覽</a>
<a href="#connectors"><span class="nav-icon">ϟ</span>充電設備<span class="nav-count">1</span></a>
<a href="#groups"><span class="nav-icon">◫</span>負載與輪充<span class="nav-count">2</span></a>
<a href="#usage"><span class="nav-icon">⌁</span>用量分析</a>
<a href="#billing"><span class="nav-icon">$</span>結算與帳務<span class="nav-count">3</span></a>
<a href="#connectors"><span class="nav-icon">◎</span>住戶與綁定</a>
<a href="#overview"><span class="nav-icon">⌘</span>工程師工具</a>
<a href="#overview"><span class="nav-icon">⚙</span>系統設定</a>
</nav>

<div class="sidebar-foot">
CloudLink EMS 2.0<br />
Demo environment
</div>
</aside>

<main id="overview">
<header class="topbar">
<div class="title-block">
<div>
<h1>案場營運總覽</h1>
<p><span class="page-site-name" id="pageSiteName">寶台 A17・地下停車場</span><span id="pageSubtitle">・即時掌握設備、供電、充電體驗與帳務狀態</span></p>
</div>
<span class="demo-badge">v0.3 Draft・展示假資料</span>
</div>

<div class="toolbar">
<div class="freshness">
<strong>● 即時連線正常</strong>
<span id="updatedAt">一般資料剛剛更新・每 5 分鐘</span>
</div>
<button class="refresh-btn" id="refreshBtn" aria-label="重新整理一般資料" title="重新整理一般資料">↻</button>
</div>
</header>

<!-- Mock review only: append ?site=config-error to preview zero/multiple enabled Building handling. -->
<section class="site-config-error" id="siteConfigError" role="alert" aria-labelledby="site-config-error-title" hidden>
<span class="site-config-error-icon" aria-hidden="true">!</span>
<div>
<div class="eyebrow">Dashboard 未載入</div>
<h2 id="site-config-error-title">案場設定異常</h2>
<p>此 EMS Branch 必須且只能有一個啟用案場。目前無法確認唯一案場,因此不顯示任何營運數字;請修正 Building 設定後再重新載入。</p>
</div>
</section>

<section class="attention" aria-labelledby="attention-title">
<div class="attention-intro">
<div class="eyebrow"><span class="pulse-dot"></span>需留意事項</div>
<h2 id="attention-title">目前有 3 類項目需要留意</h2>
</div>
<!-- Mock review only: append ?attention=partial to preview a partial aggregate. -->
<div class="attention-partial" role="status" hidden>
<span class="attention-state-icon" aria-hidden="true">!</span>
<strong>部分狀態暫時無法確認</strong>
<span class="attention-state-description">以下為已確認項目,顯示數量可能不完整</span>
</div>
<a
class="attention-item"
href="#connectors"
data-production-href="/connector/list"
aria-label="設備故障,1 個 Connector;正式版前往 Connector 列表"
>
<div class="number" style="color: var(--red)">1<small>個 Connector</small></div>
<strong>設備故障</strong>
<span>CP-04 / B2-04</span>
<span class="attention-action">查看 Connector 列表 →</span>
</a>
<a class="attention-item" href="#connectors" aria-label="Queue 阻塞,2 個項目;查看 Connector 狀態">
<div class="number" style="color: var(--amber)">2<small>個項目</small></div>
<strong>Queue 阻塞</strong>
<span>目前狀態為 BLOCKED</span>
<span class="attention-action">查看 Connector 狀態 →</span>
</a>
<a class="attention-item" href="#billing" aria-label="結算待確認,2 個步驟;查看 8 月結算流程">
<div class="number" style="color: var(--amber)">2<small>個步驟</small></div>
<strong>結算待確認</strong>
<span>8 月帳務流程未完成</span>
<span class="attention-action">查看結算流程 →</span>
</a>
<!-- Mock review only: append ?attention=empty to preview the confirmed Empty State. -->
<div class="attention-empty" role="status" hidden>
<span class="attention-empty-icon" aria-hidden="true">✓</span>
<strong id="attention-empty-title">目前沒有需留意事項</strong>
<span class="attention-empty-description">所有已檢查的狀態目前皆正常</span>
<span class="attention-empty-time">剛剛更新</span>
</div>
<!-- Mock review only: append ?attention=error to preview an unavailable aggregate. -->
<div class="attention-error" role="status" hidden>
<span class="attention-state-icon" aria-hidden="true">!</span>
<strong id="attention-error-title">需留意事項暫時無法取得</strong>
<span class="attention-state-description">請稍後使用頁面既有的重新整理功能再試一次</span>
<span class="attention-state-time">本次取得失敗・20:14</span>
</div>
</section>

<!-- KPI responsive contract: >=1240px 6x1; 768-1239px 3x2; 320-767px 2x3. DOM order stays fixed; cards never hide or become a carousel. -->
<!-- KPI interaction contract: every card is one native anchor. data-production-href documents route targets; hash targets keep this standalone mock reviewable. KPI changes are not an aria-live feed. -->
<!-- Mock review only: append ?kpi=loading|partial|error|refresh-error|stale|zero to preview the common state on the fifth card. -->
<section class="stats-grid" aria-label="即時關鍵指標" aria-live="off">
<a
class="stat-card stat-link"
id="cp-kpi"
href="#cp-kpi"
data-navigation-kind="route"
data-production-href="/cp/list"
aria-label="Charge Point 連線,4 台在線,共 4 台啟用;0 台離線,0 台尚未確認;正式版前往 Charge Point 列表"
title="只統計本案場已啟用的 Charge Point;正式版前往 /cp/list"
style="--tone: var(--green-soft)"
>
<div class="stat-label">Charge Point 連線</div>
<div class="stat-value">4 <small>/ 4 台啟用</small></div>
<div class="stat-meta good">0 台離線・0 台尚未確認</div>
</a>
<a
class="stat-card stat-link"
id="connector-serviceable-kpi"
href="#connectors"
data-navigation-kind="route"
data-production-href="/connector/list"
aria-label="Connector 可服務,11 個,共 12 個;1 個目前不可服務,其中 1 個故障;查看 Connector 即時狀態"
title="可服務不等於立即空閒;正式版前往 /connector/list"
style="--tone: var(--blue-soft)"
>
<div class="stat-label">Connector 可服務</div>
<div class="stat-value">11 <small>/ 12 個</small></div>
<div class="stat-meta warn">1 個目前不可服務・其中 1 個故障</div>
</a>
<a
class="stat-card stat-link"
id="active-session-kpi"
href="#connectors"
data-navigation-kind="route"
data-production-href="/connector/list"
aria-label="充電進行中,3 個 Sessions;依未結束的充電 Session 計算;正式版前往 Connector 列表"
title="依系統目前的 ACTIVE transaction 計算;正式版前往 /connector/list"
style="--tone: var(--teal-soft)"
>
<div class="stat-label">充電進行中</div>
<div class="stat-value">3 <small>Sessions</small></div>
<div class="stat-meta">依未結束的充電 Session 計算</div>
</a>
<a
class="stat-card stat-link"
id="waiting-power-kpi"
href="#connectors"
data-navigation-kind="route"
data-production-href="/connector/list"
aria-label="等待供電,2 個 Connector;另有 1 個未插槍,不計入;查看 Connector 即時狀態"
title="只計算 Queue 狀態為 ELIGIBLE 的 distinct Connector;正式版前往 /connector/list"
style="--tone: var(--amber-soft)"
>
<div class="stat-label">等待供電</div>
<div class="stat-value">2 <small>個</small></div>
<div class="stat-meta">另有 1 個未插槍(不計入)</div>
</a>
<a
class="stat-card stat-link"
id="today-completed-energy-kpi"
href="#usage"
data-navigation-kind="section"
aria-label="今日已完成充電量,84.6 kWh;已完成 6 次充電;查看 30 日充電用量"
title="只包含依案場時區、交易開始日歸屬至今天的有效 COMPLETED 交易;不含進行中估算"
style="--tone: #eeeafb"
>
<div class="stat-label">今日已完成充電量</div>
<div class="stat-value">84.6 <small>kWh</small></div>
<div class="stat-meta">已完成 6 次充電</div>
</a>
<a
class="stat-card stat-link"
href="#billing"
data-navigation-kind="section"
aria-label="上月電費暫無金額;8 月門牌帳單 11 筆中 10 筆計算完成;查看結算流程"
title="2026 年 8 月門牌帳單:10 / 11 筆計算完成"
style="--tone: #eef1f2"
>
<div class="stat-label">上月電費</div>
<div class="stat-value">—</div>
<div class="stat-meta warn">8 月結算未完成・10 / 11 筆</div>
</a>
</section>

<section class="dashboard-grid">
<article class="panel attention-target" id="load" tabindex="-1" aria-labelledby="load-title">
<div class="panel-head">
<div>
<h2 id="load-title">案場即時負載</h2>
<p>各充電群組目前充電功率合計</p>
</div>
<div class="segmented" aria-label="負載圖表時間範圍">
<button type="button" class="active" data-load-range="live" aria-pressed="true">即時</button>
<button type="button" data-load-range="today" aria-pressed="false">今日</button>
<button type="button" data-load-range="week" aria-pressed="false">7 日</button>
</div>
</div>
<!-- Mock review only: append ?ws=disconnected to preview the no-fallback WebSocket error state. -->
<div class="live-connection-alert" id="loadConnectionAlert" role="status">
<span class="live-connection-alert-icon" aria-hidden="true">!</span>
<div>
<strong>即時連線中斷,負載資料已停止更新</strong>
<span>以下保留最後收到的資料(20:00:12);本區塊不會切換為 REST 定時更新。</span>
</div>
</div>
<div class="load-summary">
<div class="load-number">
<span class="load-number-label">目前功率</span>
<div><span id="loadValue">21.84</span><small>kW</small></div>
</div>
<div class="load-context">
即時功率依有效、未過期的最新電表資料彙整
</div>
</div>
<div class="load-axis-guide" aria-live="polite">
<span><strong>Y 軸</strong>區間平均總充電功率(kW)</span>
<span><strong>X 軸</strong><span id="loadXAxis">最近 60 分鐘・1 分鐘 bucket</span></span>
<span><strong>時間區間</strong>最後一個 bucket 進行中</span>
</div>
<!-- Mock review: use ?load=empty&load-range=today or ?load=error&load-range=week. -->
<div class="chart-wrap" id="loadChartWrap">
<canvas id="loadChart" aria-label="最近 60 分鐘,每分鐘區間平均總充電功率折線圖"></canvas>
<div class="load-chart-state" id="loadChartState" role="status" hidden>
<span class="load-chart-state-icon" aria-hidden="true">i</span>
<strong id="loadChartStateTitle"></strong>
<span id="loadChartStateDescription"></span>
</div>
</div>
</article>

<article class="panel" id="groups" aria-labelledby="group-title">
<div class="panel-head">
<div>
<h2 id="group-title">群組容量與 Slots</h2>
<p>即時功率、容量餘裕與輪充占用</p>
</div>
<span style="color: var(--green); font-size: 10px; font-weight: 800">離峰時段</span>
</div>
<div class="groups">
<div class="group-row">
<div class="group-code">B1</div>
<div class="group-info">
<strong>7.33 / 99 kW</strong>
<span>1 charging・1 eligible</span>
<div class="meter"><span style="width: 7.4%"></span></div>
</div>
<div class="group-metric"><strong>1 / 14</strong><span>slots</span></div>
</div>
<div class="group-row">
<div class="group-code">B2</div>
<div class="group-info">
<strong>7.24 / 99 kW</strong>
<span>1 charging・1 blocked</span>
<div class="meter"><span style="width: 7.3%"></span></div>
</div>
<div class="group-metric"><strong>1 / 14</strong><span>slots</span></div>
</div>
<div class="group-row">
<div class="group-code">B3</div>
<div class="group-info">
<strong>7.28 / 99 kW</strong>
<span>1 charging・1 eligible</span>
<div class="meter"><span style="width: 7.4%"></span></div>
</div>
<div class="group-metric"><strong>1 / 14</strong><span>slots</span></div>
</div>
</div>
<div class="slots-summary">
<div><strong>3</strong><span>OCCUPIED</span></div>
<div><strong>3</strong><span>WAITING</span></div>
<div><strong>0</strong><span>RESERVED</span></div>
</div>
</article>
</section>

<section class="panel connector-panel attention-target" id="connectors" tabindex="-1" aria-labelledby="connector-title" aria-describedby="connector-summary">
<div class="panel-head">
<div>
<h2 id="connector-title">Connector 即時狀態</h2>
<p id="connector-summary">五類互斥摘要由 Backend 計算;卡片仍分層呈現 Runtime、OCPP 連線與 Queue 狀態</p>
</div>
<div class="connector-head-tools">
<div class="connector-head-meta">
<strong id="connectorTotalLabel">12 個 Connector</strong>
<span>最後更新 20:00:12</span>
<span class="connector-freshness">FRESH</span>
</div>
<div class="legend" aria-label="狀態圖例">
<span><i style="background: var(--red)"></i>Faulted</span>
<span><i style="background: var(--amber)"></i>Blocked/Waiting</span>
<span><i style="background: var(--teal)"></i>Charging</span>
<span><i style="background: var(--green)"></i>Available</span>
</div>
</div>
</div>

<!-- Mock review only: append ?ws=disconnected to preview the no-fallback WebSocket error state. -->
<div class="live-connection-alert" id="connectorConnectionAlert" role="status">
<span class="live-connection-alert-icon" aria-hidden="true">!</span>
<div>
<strong>即時連線中斷,Connector 狀態已停止更新</strong>
<span>以下保留最後收到的資料(20:00:12);本區塊不會切換為 REST 定時更新。</span>
</div>
</div>

<!-- UI-DEC-015/016/017: Backend supplies all five mutually exclusive counts. For total > 0, FE keeps every bucket visible even when its count is 0; total = 0 uses the Connector Empty State. These list items are informational and intentionally have no link, button, tab stop, or click handler. -->
<div class="connector-status-summary" id="connectorStatusSummary" role="list" aria-label="Connector 狀態摘要,共 12 個:需留意 2、充電進行中 3、準備或排隊 2、可用 4、其他狀態 1" aria-live="off">
<div class="connector-summary-item" role="listitem" data-bucket="NEEDS_ATTENTION" title="Offline、Faulted、Stale、Blocked、SUSPENDED_EVSE、Unavailable 或 Unknown">
<span>需留意</span><strong id="connectorBucketNeedsAttention">2</strong><small>個</small>
</div>
<div class="connector-summary-item" role="listitem" data-bucket="CHARGING" title="非異常的 CHARGING、SUSPENDED_EV 或 FINISHING">
<span>充電進行中</span><strong id="connectorBucketCharging">3</strong><small>個</small>
</div>
<div class="connector-summary-item" role="listitem" data-bucket="WAITING" title="非異常的 PREPARING、ENQUEUED 或 Queue ELIGIBLE">
<span>準備/排隊</span><strong id="connectorBucketWaiting">2</strong><small>個</small>
</div>
<div class="connector-summary-item" role="listitem" data-bucket="AVAILABLE" title="排除較高優先類別與特殊中性狀態後的 AVAILABLE">
<span>可用</span><strong id="connectorBucketAvailable">4</strong><small>個</small>
</div>
<div class="connector-summary-item" role="listitem" data-bucket="OTHER" title="RESERVED、NOT_PLUGGED、PAUSED、FINISHED 等其他中性狀態">
<span>其他狀態</span><strong id="connectorBucketOther">1</strong><small>個</small>
</div>
</div>

<!-- Mock review only: append ?connectors=0|1|2|8|9|30|100 to verify UI-DEC-016 zero-count and small/large-site behavior. -->
<div class="connector-empty" id="connectorPreviewEmpty" role="status" hidden>
<strong>尚未建立 Connector</strong>
<span>此案場目前沒有可顯示的 Connector 資料。</span>
<a href="#connectors" data-production-href="/connector/list">前往 Connector 管理頁 →</a>
</div>

<!-- UI-DEC-013 fixture: this DOM represents one Backend-ranked Top-8 payload. FE only hides the tail at Tablet/Mobile breakpoints. -->
<div class="connector-grid" id="connectorPreviewGrid" aria-label="Backend 優先排序後的 Connector 預覽">
<div class="connector problem" tabindex="0" data-id="B2-04" data-display-bucket="NEEDS_ATTENTION" data-is-abnormal="true" data-priority-reason="CONNECTOR_FAULTED" data-severity="ERROR" data-detail="區域未設定・CloudLink・Faulted / GroundFailure・最後更新 9 分前">
<div class="connector-top"><span class="connector-id">B2-04</span><span class="connector-status" style="color: var(--red)"><i class="status-dot" style="background: var(--red)"></i>FAULTED</span></div>
<div class="connector-power">— <small>kW</small></div>
<div class="connector-meta"><span>AREA NOT SET</span><span style="color: var(--red)">ERROR</span></div>
</div>
<div class="connector warning" tabindex="0" data-id="B2-02" data-display-bucket="NEEDS_ATTENTION" data-is-abnormal="true" data-priority-reason="QUEUE_BLOCKED" data-severity="WARNING" data-detail="私人・CloudLink・Queue: blocked・設備狀態 Available・最後更新 1 分前">
<div class="connector-top"><span class="connector-id">B2-02</span><span class="connector-status"><i class="status-dot" style="background: var(--green)"></i>AVAILABLE</span></div>
<div class="connector-power">0.00 <small>kW</small></div>
<div class="connector-meta"><span>PRIVATE・CLOUDLINK</span><span class="queue-pill">BLOCKED</span></div>
</div>
<div class="connector" tabindex="0" data-id="B1-01" data-display-bucket="CHARGING" data-is-abnormal="false" data-priority-reason="CONNECTOR_CHARGING" data-severity="INFO" data-detail="私人・CloudLink・Queue: charging・最後更新 18 秒前">
<div class="connector-top"><span class="connector-id">B1-01</span><span class="connector-status"><i class="status-dot" style="background: var(--teal)"></i>CHARGING</span></div>
<div class="connector-power">7.33 <small>kW</small></div>
<div class="connector-meta"><span>PRIVATE・CLOUDLINK</span><span>01:26</span></div>
</div>
<div class="connector" tabindex="0" data-id="B2-01" data-display-bucket="CHARGING" data-is-abnormal="false" data-priority-reason="CONNECTOR_CHARGING" data-severity="INFO" data-detail="私人・CloudLink・Queue: charging・最後更新 20 秒前">
<div class="connector-top"><span class="connector-id">B2-01</span><span class="connector-status"><i class="status-dot" style="background: var(--teal)"></i>CHARGING</span></div>
<div class="connector-power">7.24 <small>kW</small></div>
<div class="connector-meta"><span>PRIVATE・CLOUDLINK</span><span>00:58</span></div>
</div>
<div class="connector" tabindex="0" data-id="B3-01" data-display-bucket="CHARGING" data-is-abnormal="false" data-priority-reason="CONNECTOR_CHARGING" data-severity="INFO" data-detail="私人・CloudLink・Queue: charging・最後更新 14 秒前">
<div class="connector-top"><span class="connector-id">B3-01</span><span class="connector-status"><i class="status-dot" style="background: var(--teal)"></i>CHARGING</span></div>
<div class="connector-power">7.28 <small>kW</small></div>
<div class="connector-meta"><span>PRIVATE・CLOUDLINK</span><span>01:12</span></div>
</div>
<div class="connector" tabindex="0" data-id="B1-02" data-display-bucket="WAITING" data-is-abnormal="false" data-priority-reason="QUEUE_WAITING" data-severity="INFO" data-detail="私人・CloudLink・Queue: eligible・順位 1・最後更新 1 分前">
<div class="connector-top"><span class="connector-id">B1-02</span><span class="connector-status"><i class="status-dot" style="background: var(--green)"></i>AVAILABLE</span></div>
<div class="connector-power">0.00 <small>kW</small></div>
<div class="connector-meta"><span>PRIVATE・CLOUDLINK</span><span class="queue-pill">QUEUE #1</span></div>
</div>
<div class="connector" tabindex="0" data-id="B3-02" data-display-bucket="WAITING" data-is-abnormal="false" data-priority-reason="QUEUE_WAITING" data-severity="INFO" data-detail="私人・CloudLink・Queue: eligible・順位 2・最後更新 1 分前">
<div class="connector-top"><span class="connector-id">B3-02</span><span class="connector-status"><i class="status-dot" style="background: var(--green)"></i>AVAILABLE</span></div>
<div class="connector-power">0.00 <small>kW</small></div>
<div class="connector-meta"><span>PRIVATE・CLOUDLINK</span><span class="queue-pill">QUEUE #2</span></div>
</div>
<div class="connector" tabindex="0" data-id="B1-03" data-display-bucket="AVAILABLE" data-is-abnormal="false" data-priority-reason="CONNECTOR_AVAILABLE" data-severity="INFO" data-detail="私人・CloudLink・未進入 Queue・最後更新 2 分前">
<div class="connector-top"><span class="connector-id">B1-03</span><span class="connector-status"><i class="status-dot" style="background: var(--green)"></i>AVAILABLE</span></div>
<div class="connector-power">0.00 <small>kW</small></div>
<div class="connector-meta"><span>PRIVATE・CLOUDLINK</span><span>ONLINE</span></div>
</div>
</div>

<div class="connector-preview-footer" id="connectorPreviewFooter">
<div class="connector-preview-count" id="connectorPreviewCount" aria-live="polite">
目前顯示 <strong><span id="connectorVisibleCount">8</span> / <span id="connectorPreviewTotal">12</span></strong>
<span id="connectorHiddenCount">・另有 4 個未顯示</span>
<span class="connector-hidden-abnormal" id="connectorHiddenAbnormal" hidden></span>
</div>
<button class="view-all-connectors" id="openConnectorList" type="button" aria-haspopup="dialog" aria-controls="connectorListDrawer" aria-expanded="false">
查看全部 12 個 Connector →
</button>
</div>

<div class="detail-drawer" id="connectorDetail" aria-live="polite">
<div><strong id="detailTitle">B1-01</strong><br /><span id="detailText"></span></div>
<button type="button">查看設備詳情 →</button>
</div>
</section>

<section class="bottom-grid">
<article class="panel attention-target" id="usage" tabindex="-1" aria-labelledby="usage-title">
<div class="panel-head">
<div>
<h2 id="usage-title">30 日充電用量</h2>
<p>Sessions、有效電量與平均單次用量</p>
</div>
<div class="segmented" aria-label="用量圖表指標">
<button class="active" data-usage-mode="energy">kWh</button>
<button data-usage-mode="sessions">Sessions</button>
</div>
</div>
<div class="usage-layout">
<div class="usage-chart"><canvas id="usageChart" aria-label="30 日充電用量長條圖"></canvas></div>
<div class="usage-facts">
<div class="fact"><span>有效充電量</span><strong>2,213.6 kWh</strong></div>
<div class="fact"><span>充電 Sessions</span><strong>136</strong></div>
<div class="fact" title="有效充電量 ÷ 充電 Sessions"><span>平均每次充電量</span><strong>16.28 kWh/次</strong></div>
</div>
</div>
</article>

<article class="panel attention-target" id="billing" tabindex="-1" aria-labelledby="billing-title">
<div class="panel-head">
<div>
<h2 id="billing-title">8 月結算流程</h2>
<p>從交易資料到住戶帳務的完成狀態</p>
</div>
<span style="color: var(--amber); font-size: 10px; font-weight: 800">待確認</span>
</div>
<div class="pipeline">
<div class="pipeline-row">
<div class="pipeline-index">✓</div>
<div class="pipeline-info"><strong>Connector 結算</strong><span>設備電量與費率計算</span></div>
<div class="pipeline-value" style="color: var(--red)">11 / 12</div>
</div>
<div class="pipeline-row">
<div class="pipeline-index">✓</div>
<div class="pipeline-info"><strong>門牌結算</strong><span>私人與公用費用歸戶</span></div>
<div class="pipeline-value" style="color: var(--red)">10 / 11</div>
</div>
<div class="pipeline-row">
<div class="pipeline-index pending">3</div>
<div class="pipeline-info"><strong>帳務覆核</strong><span>7 paid・4 pending</span></div>
<div class="pipeline-value" style="color: var(--amber)">進行中</div>
</div>
<div class="pipeline-row">
<div class="pipeline-index pending">4</div>
<div class="pipeline-info"><strong>發票結算</strong><span>目前狀態 draft</span></div>
<div class="pipeline-value" style="color: var(--red)">未完成</div>
</div>
</div>
</article>
</section>

<div class="footnote">v0.3 Draft・2026-09-07|全案場共用設計;此頁以 A17 為展示案例,所有數字皆為 UI 假資料,不代表案場即時營運結果。</div>
</main>
</div>

<!-- UI-DEC-018~020 review fixture: search/filter/page state belongs to the current Dashboard page session. The list uses fixed Backend priority sorting with no user sorting control. -->
<div class="connector-list-backdrop" id="connectorListBackdrop" hidden>
<aside class="connector-list-drawer" id="connectorListDrawer" role="dialog" aria-modal="true" aria-labelledby="connector-list-title" tabindex="-1" data-state-scope="page-session">
<div class="connector-list-head">
<div>
<div class="eyebrow">Connector 完整清單</div>
<h2 id="connector-list-title">全部 12 個 Connector</h2>
<p>固定沿用 Dashboard 的 Backend priority sorting,不提供使用者排序控制;完整資料由 REST search API 取得。</p>
</div>
<button class="drawer-close" id="closeConnectorList" type="button" aria-label="關閉 Connector 完整清單">×</button>
</div>

<div class="connector-list-controls">
<label>
搜尋
<input id="connectorListSearch" type="search" placeholder="Connector ID、Charge Point ID、車位" autocomplete="off" />
</label>
<label>
狀態
<select id="connectorListStatus">
<option value="">全部狀態</option>
<option value="FAULTED">Faulted</option>
<option value="BLOCKED">Blocked</option>
<option value="CHARGING">Charging</option>
<option value="WAITING">Waiting</option>
<option value="AVAILABLE">Available</option>
</select>
</label>
<label>
Charge Group
<select id="connectorListGroup">
<option value="">全部群組</option>
<option value="B1">B1</option>
<option value="B2">B2</option>
<option value="B3">B3</option>
</select>
</label>
</div>
<div class="connector-list-note">此 Mock 以完整本地假資料模擬 Server 搜尋/篩選、需留意優先排序與固定每頁 20 筆;正式版每次條件或頁碼變更均送至 REST API。</div>

<div class="connector-list" id="connectorFullList" role="list" aria-label="Connector 搜尋結果">
<div class="connector-list-row is-abnormal" role="listitem" data-search="b2-04 cp-04 區域未設定 cloudlink" data-status="FAULTED" data-group="B2" data-display-bucket="NEEDS_ATTENTION">
<strong>B2-04</strong><span class="row-cp">CP-04</span><span class="row-area">區域未設定</span><span class="row-state">FAULTED・GroundFailure</span><a class="row-detail" href="#connectors" data-production-href="/connector/list">查看 →</a>
</div>
<div class="connector-list-row is-abnormal" role="listitem" data-search="b2-02 cp-02 私人 cloudlink" data-status="BLOCKED" data-group="B2" data-display-bucket="NEEDS_ATTENTION">
<strong>B2-02</strong><span class="row-cp">CP-02</span><span class="row-area">私人車位</span><span class="row-state">AVAILABLE・BLOCKED</span><a class="row-detail" href="#connectors" data-production-href="/connector/list">查看 →</a>
</div>
<div class="connector-list-row" role="listitem" data-search="b1-01 cp-01 私人 cloudlink" data-status="CHARGING" data-group="B1" data-display-bucket="CHARGING">
<strong>B1-01</strong><span class="row-cp">CP-01</span><span class="row-area">私人車位</span><span class="row-state">CHARGING・7.33 kW</span><a class="row-detail" href="#connectors" data-production-href="/connector/list">查看 →</a>
</div>
<div class="connector-list-row" role="listitem" data-search="b2-01 cp-02 私人 cloudlink" data-status="CHARGING" data-group="B2" data-display-bucket="CHARGING">
<strong>B2-01</strong><span class="row-cp">CP-02</span><span class="row-area">私人車位</span><span class="row-state">CHARGING・7.24 kW</span><a class="row-detail" href="#connectors" data-production-href="/connector/list">查看 →</a>
</div>
<div class="connector-list-row" role="listitem" data-search="b3-01 cp-03 私人 cloudlink" data-status="CHARGING" data-group="B3" data-display-bucket="CHARGING">
<strong>B3-01</strong><span class="row-cp">CP-03</span><span class="row-area">私人車位</span><span class="row-state">CHARGING・7.28 kW</span><a class="row-detail" href="#connectors" data-production-href="/connector/list">查看 →</a>
</div>
<div class="connector-list-row" role="listitem" data-search="b1-02 cp-01 私人 cloudlink" data-status="WAITING" data-group="B1" data-display-bucket="WAITING">
<strong>B1-02</strong><span class="row-cp">CP-01</span><span class="row-area">私人車位</span><span class="row-state">AVAILABLE・QUEUE #1</span><a class="row-detail" href="#connectors" data-production-href="/connector/list">查看 →</a>
</div>
<div class="connector-list-row" role="listitem" data-search="b3-02 cp-03 私人 cloudlink" data-status="WAITING" data-group="B3" data-display-bucket="WAITING">
<strong>B3-02</strong><span class="row-cp">CP-03</span><span class="row-area">私人車位</span><span class="row-state">AVAILABLE・QUEUE #2</span><a class="row-detail" href="#connectors" data-production-href="/connector/list">查看 →</a>
</div>
<div class="connector-list-row" role="listitem" data-search="b1-03 cp-01 私人 cloudlink" data-status="AVAILABLE" data-group="B1" data-display-bucket="AVAILABLE">
<strong>B1-03</strong><span class="row-cp">CP-01</span><span class="row-area">私人車位</span><span class="row-state">AVAILABLE</span><a class="row-detail" href="#connectors" data-production-href="/connector/list">查看 →</a>
</div>
<div class="connector-list-row" role="listitem" data-search="b1-04 cp-01 公用 cloudlink" data-status="AVAILABLE" data-group="B1" data-display-bucket="AVAILABLE">
<strong>B1-04</strong><span class="row-cp">CP-01</span><span class="row-area">公用車位</span><span class="row-state">AVAILABLE</span><a class="row-detail" href="#connectors" data-production-href="/connector/list">查看 →</a>
</div>
<div class="connector-list-row" role="listitem" data-search="b1-05 cp-01 私人 fortune" data-status="AVAILABLE" data-group="B1" data-display-bucket="AVAILABLE">
<strong>B1-05</strong><span class="row-cp">CP-01</span><span class="row-area">私人車位</span><span class="row-state">AVAILABLE</span><a class="row-detail" href="#connectors" data-production-href="/connector/list">查看 →</a>
</div>
<div class="connector-list-row" role="listitem" data-search="b3-03 cp-03 私人 cloudlink" data-status="AVAILABLE" data-group="B3" data-display-bucket="AVAILABLE">
<strong>B3-03</strong><span class="row-cp">CP-03</span><span class="row-area">私人車位</span><span class="row-state">AVAILABLE</span><a class="row-detail" href="#connectors" data-production-href="/connector/list">查看 →</a>
</div>
<div class="connector-list-row" role="listitem" data-search="b3-04 cp-03 私人 cloudlink no plug" data-status="WAITING" data-group="B3" data-display-bucket="OTHER">
<strong>B3-04</strong><span class="row-cp">CP-03</span><span class="row-area">私人車位</span><span class="row-state">AVAILABLE・NO PLUG</span><a class="row-detail" href="#connectors" data-production-href="/connector/list">查看 →</a>
</div>
</div>
<div class="connector-list-empty" id="connectorListEmpty" role="status" hidden>沒有符合目前條件的 Connector。</div>
<div class="connector-list-footer">
<span id="connectorListPageSummary" aria-live="polite">共 12 個</span>
<div class="connector-list-pagination" id="connectorListPagination" aria-label="Connector 完整清單分頁" hidden>
<button id="connectorListPreviousPage" type="button">← 上一頁</button>
<button id="connectorListNextPage" type="button">下一頁 →</button>
</div>
<a href="#connectors" data-production-href="/connector/list">前往 Connector 管理頁 →</a>
</div>
</aside>
</div>

<script>
const colors = {
teal: "#11a891",
tealFill: "rgba(17, 168, 145, 0.12)",
ink: "#10252b",
muted: "#718387",
line: "#dfe8e8",
amber: "#d48a19",
}

function createFakeLoadValues(length, base, amplitude, period, trend = 0, phase = 0) {
return Array.from({ length }, (_, index) => {
const progress = length === 1 ? 0 : index / (length - 1)
const primaryWave = Math.sin((index + phase) / period) * amplitude
const secondaryWave = Math.max(0, Math.sin((index + phase) / (period * 0.43))) * 2.1
return Number(Math.max(0, base + primaryWave + secondaryWave + progress * trend).toFixed(2))
})
}

function endAtCurrentPower(values) {
values[values.length - 1] = 21.84
return values
}

const loadSeries = {
live: {
values: endAtCurrentPower(createFakeLoadValues(60, 13.8, 3.1, 5.8, 4.2, 2)),
xAxis: "最近 60 分鐘・1 分鐘 bucket",
ariaLabel: "最近 60 分鐘,每分鐘區間平均總充電功率折線圖",
tooltipExample: "每點代表 1 分鐘區間平均功率;最後一點為進行中 bucket",
xTicks: [
{ ratio: 0, label: "19:00" },
{ ratio: 0.25, label: "19:15" },
{ ratio: 0.5, label: "19:30" },
{ ratio: 0.75, label: "19:45" },
{ ratio: 1, label: "20:00" },
],
},
today: {
values: endAtCurrentPower(createFakeLoadValues(81, 8.2, 5.3, 8.4, 5.8, 4)),
xAxis: "今日 00:00 至現在・15 分鐘 bucket",
ariaLabel: "案場今日零時至現在,每十五分鐘區間平均總充電功率折線圖",
tooltipExample: "每點代表 15 分鐘區間平均功率;最後一點為進行中 bucket",
xTicks: [
{ ratio: 0, label: "00:00" },
{ ratio: 0.3, label: "06:00" },
{ ratio: 0.6, label: "12:00" },
{ ratio: 0.9, label: "18:00" },
{ ratio: 1, label: "20:00" },
],
},
week: {
values: endAtCurrentPower(createFakeLoadValues(165, 9.6, 6.4, 6.1, 2.8, 1)),
xAxis: "含今天 7 個案場日曆日・1 小時 bucket",
ariaLabel: "包含今天的七個案場日曆日,每小時區間平均總充電功率折線圖",
tooltipExample: "每點代表 1 小時區間平均功率;最後一點為進行中 bucket",
xTicks: [
{ ratio: 0, label: "8/28" },
{ ratio: 0.146, label: "8/29" },
{ ratio: 0.293, label: "8/30" },
{ ratio: 0.439, label: "8/31" },
{ ratio: 0.585, label: "9/1" },
{ ratio: 0.732, label: "9/2" },
{ ratio: 0.878, label: "9/3" },
{ ratio: 1, label: "20:00" },
],
},
}

const usageSeries = {
energy: [132, 212, 106, 38, 86, 127, 111, 101, 109, 226, 122, 75, 147, 11, 84.6],
sessions: [10, 10, 4, 4, 4, 5, 3, 4, 4, 14, 10, 4, 7, 4, 6],
}

function setupCanvas(canvas) {
const rect = canvas.getBoundingClientRect()
const ratio = window.devicePixelRatio || 1
canvas.width = Math.max(1, Math.floor(rect.width * ratio))
canvas.height = Math.max(1, Math.floor(rect.height * ratio))
const ctx = canvas.getContext("2d")
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
return { ctx, width: rect.width, height: rect.height }
}

function drawLineChart(canvas, series) {
const { ctx, width, height } = setupCanvas(canvas)
const values = series.values
const pad = { top: 17, right: 12, bottom: 25, left: 42 }
const chartW = width - pad.left - pad.right
const chartH = height - pad.top - pad.bottom
const rawMax = Math.max(...values)
const step = rawMax <= 30 ? 5 : rawMax <= 100 ? 10 : 25
const max = Math.max(step, Math.ceil(rawMax / step) * step)
const points = values.map((value, i) => ({
x: pad.left + (i / (values.length - 1)) * chartW,
y: pad.top + chartH - (value / max) * chartH,
}))

ctx.strokeStyle = "#e3ecec"
ctx.lineWidth = 1
ctx.fillStyle = colors.muted
ctx.font = "9px system-ui"
for (let index = 0; index <= 4; index += 1) {
const ratio = index / 4
const y = pad.top + ratio * chartH
const value = max * (1 - ratio)
ctx.beginPath()
ctx.moveTo(pad.left, y)
ctx.lineTo(width - pad.right, y)
ctx.stroke()
ctx.textAlign = "right"
ctx.textBaseline = "middle"
ctx.fillText(value.toFixed(0), pad.left - 7, y)
}

ctx.textAlign = "left"
ctx.textBaseline = "top"
ctx.fillText("kW", 5, 3)

const gradient = ctx.createLinearGradient(0, pad.top, 0, height)
gradient.addColorStop(0, "rgba(17,168,145,.24)")
gradient.addColorStop(1, "rgba(17,168,145,0)")

ctx.beginPath()
ctx.moveTo(points[0].x, pad.top + chartH)
points.forEach((point) => ctx.lineTo(point.x, point.y))
ctx.lineTo(points[points.length - 1].x, pad.top + chartH)
ctx.closePath()
ctx.fillStyle = gradient
ctx.fill()

ctx.beginPath()
points.forEach((point, i) => (i === 0 ? ctx.moveTo(point.x, point.y) : ctx.lineTo(point.x, point.y)))
ctx.strokeStyle = colors.teal
ctx.lineWidth = 2.5
ctx.lineJoin = "round"
ctx.lineCap = "round"
ctx.stroke()

const last = points[points.length - 1]
ctx.beginPath()
ctx.arc(last.x, last.y, 4.5, 0, Math.PI * 2)
ctx.fillStyle = "#fff"
ctx.fill()
ctx.lineWidth = 3
ctx.strokeStyle = colors.teal
ctx.stroke()

ctx.fillStyle = colors.muted
ctx.font = "10px system-ui"
ctx.textBaseline = "alphabetic"
const visibleTicks = width < 430
? series.xTicks.filter((_, index) => index === 0 || index === series.xTicks.length - 1 || index % 2 === 0)
: series.xTicks
visibleTicks.forEach((tick) => {
const x = pad.left + tick.ratio * chartW
ctx.textAlign = tick.ratio === 0 ? "left" : tick.ratio === 1 ? "right" : "center"
ctx.fillText(tick.label, x, height - 5)
})
}

function drawBarChart(canvas, values, mode) {
const { ctx, width, height } = setupCanvas(canvas)
const pad = { top: 15, right: 8, bottom: 24, left: 8 }
const chartW = width - pad.left - pad.right
const chartH = height - pad.top - pad.bottom
const max = Math.max(...values) * 1.15
const gap = 6
const barW = Math.max(4, (chartW - gap * (values.length - 1)) / values.length)

values.forEach((value, index) => {
const h = (value / max) * chartH
const x = pad.left + index * (barW + gap)
const y = pad.top + chartH - h
const gradient = ctx.createLinearGradient(0, y, 0, pad.top + chartH)
gradient.addColorStop(0, mode === "energy" ? colors.teal : colors.amber)
gradient.addColorStop(1, mode === "energy" ? "#7ed9c7" : "#f0c374")
ctx.fillStyle = gradient
ctx.beginPath()
ctx.roundRect(x, y, barW, h, Math.min(4, barW / 2))
ctx.fill()
})

ctx.fillStyle = colors.muted
ctx.font = "10px system-ui"
ctx.textAlign = "left"
ctx.fillText("30 日前", pad.left, height - 4)
ctx.textAlign = "right"
ctx.fillText("今天", width - pad.right, height - 4)
}

const loadCanvas = document.getElementById("loadChart")
const usageCanvas = document.getElementById("usageChart")
let currentLoadRange = "live"
let currentUsageMode = "energy"

const mockParams = new URLSearchParams(window.location.search)
const mockSiteState = mockParams.get("site")
const mockWsState = mockParams.get("ws")
const mockLoadState = mockParams.get("load")
const mockLoadTargetRange = mockParams.get("load-range")
const requestedConnectorTotal = Number(mockParams.get("connectors"))
const supportedConnectorTotals = new Set([0, 1, 2, 8, 9, 30, 100])
const mockConnectorTotal = mockParams.has("connectors") && supportedConnectorTotals.has(requestedConnectorTotal)
? requestedConnectorTotal
: 12
const connectorBucketOrder = ["NEEDS_ATTENTION", "CHARGING", "WAITING", "AVAILABLE", "OTHER"]
const connectorBucketLabels = {
NEEDS_ATTENTION: "需留意",
CHARGING: "充電進行中",
WAITING: "準備或排隊",
AVAILABLE: "可用",
OTHER: "其他狀態",
}
const connectorSummaryFixtures = {
0: { NEEDS_ATTENTION: 0, CHARGING: 0, WAITING: 0, AVAILABLE: 0, OTHER: 0 },
1: { NEEDS_ATTENTION: 1, CHARGING: 0, WAITING: 0, AVAILABLE: 0, OTHER: 0 },
2: { NEEDS_ATTENTION: 2, CHARGING: 0, WAITING: 0, AVAILABLE: 0, OTHER: 0 },
8: { NEEDS_ATTENTION: 2, CHARGING: 3, WAITING: 2, AVAILABLE: 1, OTHER: 0 },
9: { NEEDS_ATTENTION: 2, CHARGING: 3, WAITING: 2, AVAILABLE: 2, OTHER: 0 },
12: { NEEDS_ATTENTION: 2, CHARGING: 3, WAITING: 2, AVAILABLE: 4, OTHER: 1 },
30: { NEEDS_ATTENTION: 2, CHARGING: 6, WAITING: 5, AVAILABLE: 14, OTHER: 3 },
100: { NEEDS_ATTENTION: 2, CHARGING: 15, WAITING: 20, AVAILABLE: 55, OTHER: 8 },
}
const mockConnectorBucketCounts = connectorSummaryFixtures[mockConnectorTotal]
const supportedLoadRanges = new Set(["live", "today", "week"])
const supportedMockLoadStates = new Set(["empty", "error"])
if (supportedMockLoadStates.has(mockLoadState) && supportedLoadRanges.has(mockLoadTargetRange)) {
currentLoadRange = mockLoadTargetRange
}
const dashboardRoot = document.getElementById("overview")
const siteIdentity = document.getElementById("siteIdentity")
const sidebarSiteName = document.getElementById("sidebarSiteName")
const pageSiteName = document.getElementById("pageSiteName")
const pageSubtitle = document.getElementById("pageSubtitle")
const freshness = document.querySelector(".freshness")
const refreshButton = document.getElementById("refreshBtn")
const loadChartWrap = document.getElementById("loadChartWrap")
const loadChartState = document.getElementById("loadChartState")
const loadChartStateTitle = document.getElementById("loadChartStateTitle")
const loadChartStateDescription = document.getElementById("loadChartStateDescription")
const connectorPreviewItems = Array.from(document.querySelectorAll("#connectorPreviewGrid .connector"))
const connectorPreviewPayload = {
total: mockConnectorTotal,
abnormalTotal: mockConnectorBucketCounts.NEEDS_ATTENTION,
buckets: connectorBucketOrder.map((code) => ({ code, count: mockConnectorBucketCounts[code] })),
items: connectorPreviewItems.slice(0, Math.min(mockConnectorTotal, 8)).map((item) => ({
connectorId: item.dataset.id,
displayBucket: item.dataset.displayBucket,
isAbnormal: item.dataset.isAbnormal === "true",
severity: item.dataset.severity,
priorityReason: item.dataset.priorityReason,
})),
}

const connectorSummaryTotal = connectorPreviewPayload.buckets.reduce((total, bucket) => total + bucket.count, 0)
if (connectorSummaryTotal !== connectorPreviewPayload.total) {
throw new Error("Connector summary bucket counts must equal total")
}
if (mockConnectorBucketCounts.NEEDS_ATTENTION !== connectorPreviewPayload.abnormalTotal) {
throw new Error("NEEDS_ATTENTION count must equal abnormalTotal")
}
connectorPreviewPayload.items.forEach((item) => {
if ((item.displayBucket === "NEEDS_ATTENTION") !== item.isAbnormal) {
throw new Error(`Connector ${item.connectorId} has inconsistent displayBucket and isAbnormal`)
}
})

function getConnectorPreviewLimit() {
if (window.innerWidth >= 1240) return 8
if (window.innerWidth >= 640) return 6
return 4
}

function syncConnectorPreview() {
const limit = getConnectorPreviewLimit()
connectorPreviewItems.forEach((item, index) => {
item.hidden = index >= connectorPreviewPayload.items.length || index >= limit
})

const visibleItems = connectorPreviewPayload.items.slice(0, limit)
const visibleCount = visibleItems.length
const visibleAbnormalCount = visibleItems.filter((item) => item.isAbnormal).length
const hiddenCount = Math.max(0, connectorPreviewPayload.total - visibleCount)
const hiddenAbnormalCount = Math.max(0, connectorPreviewPayload.abnormalTotal - visibleAbnormalCount)

document.getElementById("connectorTotalLabel").textContent = `${connectorPreviewPayload.total} 個 Connector`
document.getElementById("connectorVisibleCount").textContent = String(visibleCount)
document.getElementById("connectorPreviewTotal").textContent = String(connectorPreviewPayload.total)
document.getElementById("connectorHiddenCount").textContent = hiddenCount > 0 ? `・另有 ${hiddenCount} 個未顯示` : "・已全部顯示"

const hasConnector = connectorPreviewPayload.total > 0
const connectorStatusSummary = document.getElementById("connectorStatusSummary")
connectorStatusSummary.hidden = !hasConnector
connectorPreviewPayload.buckets.forEach((bucket) => {
const elementId = {
NEEDS_ATTENTION: "connectorBucketNeedsAttention",
CHARGING: "connectorBucketCharging",
WAITING: "connectorBucketWaiting",
AVAILABLE: "connectorBucketAvailable",
OTHER: "connectorBucketOther",
}[bucket.code]
document.getElementById(elementId).textContent = String(bucket.count)
})
const summaryDescription = connectorPreviewPayload.buckets
.map((bucket) => `${connectorBucketLabels[bucket.code]} ${bucket.count}`)
.join("、")
connectorStatusSummary.setAttribute(
"aria-label",
`Connector 狀態摘要,共 ${connectorPreviewPayload.total} 個:${summaryDescription}`,
)
document.getElementById("connectorPreviewGrid").hidden = !hasConnector
document.getElementById("connectorPreviewEmpty").hidden = hasConnector
document.getElementById("connectorPreviewFooter").hidden = !hasConnector
const openConnectorListControl = document.getElementById("openConnectorList")
openConnectorListControl.hidden = !hasConnector || hiddenCount === 0
openConnectorListControl.textContent = `查看全部 ${connectorPreviewPayload.total} 個 Connector →`

const hiddenAbnormal = document.getElementById("connectorHiddenAbnormal")
hiddenAbnormal.hidden = hiddenAbnormalCount === 0
hiddenAbnormal.textContent = hiddenAbnormalCount > 0 ? `其中 ${hiddenAbnormalCount} 個為異常 Connector` : ""
}

if (mockSiteState === "config-error") {
dashboardRoot.classList.add("has-site-config-error")
document.getElementById("siteConfigError").hidden = false
siteIdentity.classList.add("is-error")
siteIdentity.setAttribute("aria-label", "案場設定異常:無法識別唯一啟用案場")
siteIdentity.querySelector("small").textContent = "案場設定異常"
sidebarSiteName.textContent = "無法識別唯一案場"
siteIdentity.querySelector("span").textContent = "啟用中的 Building 數量不正確"
pageSiteName.textContent = "案場未識別"
pageSubtitle.textContent = "・無法載入此 EMS Branch 的 Dashboard 資料"
freshness.classList.add("is-error")
freshness.querySelector("strong").textContent = "● Dashboard 未載入"
document.getElementById("updatedAt").textContent = "一般資料未載入・需先修正案場設定"
refreshButton.disabled = true
refreshButton.setAttribute("aria-label", "重新整理一般資料不可用:案場設定異常")
}

if (mockWsState === "disconnected" && mockSiteState !== "config-error") {
freshness.classList.add("is-warning")
freshness.querySelector("strong").textContent = "● 即時連線中斷"
document.getElementById("load").classList.add("is-live-disconnected")
document.getElementById("connectors").classList.add("is-live-disconnected")
}

const attentionPanel = document.querySelector(".attention")
const mockAttentionState = mockParams.get("attention")
const attentionIntro = attentionPanel.querySelector(".attention-intro")
const attentionItems = attentionPanel.querySelectorAll(".attention-item")
const attentionTitle = attentionPanel.querySelector("#attention-title")

function hideAttentionCategories() {
attentionIntro.hidden = true
attentionItems.forEach((item) => {
item.hidden = true
})
}

if (mockAttentionState === "empty") {
attentionPanel.classList.add("is-empty")
hideAttentionCategories()
attentionPanel.querySelector(".attention-empty").hidden = false
attentionPanel.setAttribute("aria-labelledby", "attention-empty-title")
} else if (mockAttentionState === "partial") {
attentionPanel.classList.add("is-partial")
attentionTitle.textContent = "已確認 3 類項目需要留意"
attentionPanel.querySelector(".attention-partial").hidden = false
} else if (mockAttentionState === "error") {
attentionPanel.classList.add("is-error")
hideAttentionCategories()
attentionPanel.querySelector(".attention-error").hidden = false
attentionPanel.setAttribute("aria-labelledby", "attention-error-title")
}

const mockKpiState = mockParams.get("kpi")
const mockKpiCard = document.getElementById("today-completed-energy-kpi")
const mockKpiValue = mockKpiCard.querySelector(".stat-value")
const mockKpiMeta = mockKpiCard.querySelector(".stat-meta")
const mockKpiStates = {
partial: {
className: "kpi-state-partial",
valueMarkup: '84.6 <small>kWh</small>',
meta: "部分資料缺漏・數字可能不完整",
ariaLabel: "今日已完成充電量,已知 84.6 kWh;部分資料缺漏,數字可能不完整;查看 30 日充電用量",
},
error: {
className: "kpi-state-error",
valueMarkup: "—",
meta: "暫時無法取得",
ariaLabel: "今日已完成充電量暫時無法取得;查看 30 日充電用量",
},
"refresh-error": {
className: "kpi-state-refresh-error",
valueMarkup: '84.6 <small>kWh</small>',
meta: "更新失敗・顯示 14:50 資料",
ariaLabel: "今日已完成充電量,84.6 kWh;更新失敗,目前顯示最後成功更新於 14 點 50 分的資料;查看 30 日充電用量",
},
stale: {
className: "kpi-state-stale",
valueMarkup: '84.6 <small>kWh</small>',
meta: "資料已過期・最後更新 14:50",
ariaLabel: "今日已完成充電量,84.6 kWh;資料已過期,最後更新於 14 點 50 分;查看 30 日充電用量",
},
zero: {
className: "kpi-state-zero",
valueMarkup: '0 <small>kWh</small>',
meta: "已完成 0 次充電",
ariaLabel: "今日已完成充電量,0 kWh;已完成 0 次充電;查看 30 日充電用量",
},
}

if (mockKpiState === "loading") {
mockKpiCard.classList.add("kpi-state-loading")
mockKpiCard.setAttribute("aria-busy", "true")
mockKpiCard.setAttribute("aria-label", "今日已完成充電量載入中;查看 30 日充電用量")
mockKpiValue.innerHTML = '<span class="kpi-skeleton kpi-skeleton-value" aria-hidden="true"></span>'
mockKpiMeta.innerHTML = '<span class="kpi-skeleton kpi-skeleton-meta" aria-hidden="true"></span>'
} else if (mockKpiStates[mockKpiState]) {
const state = mockKpiStates[mockKpiState]
mockKpiCard.classList.add(state.className)
mockKpiValue.innerHTML = state.valueMarkup
mockKpiMeta.textContent = state.meta
mockKpiCard.setAttribute("aria-label", state.ariaLabel)
}

document.querySelectorAll(".attention-item[href^='#'], .stat-link[href^='#']").forEach((link) => {
link.addEventListener("click", () => {
const target = document.querySelector(link.getAttribute("href"))
if (!target) return
window.requestAnimationFrame(() => target.focus({ preventScroll: true }))
})
})

function renderLoadChart() {
const series = loadSeries[currentLoadRange]
document.getElementById("loadXAxis").textContent = series.xAxis
const rangeLabels = { live: "即時", today: "今日", week: "7 日" }
const hasMockState =
supportedMockLoadStates.has(mockLoadState) && currentLoadRange === mockLoadTargetRange

loadChartWrap.classList.toggle("has-chart-state", hasMockState)
loadChartState.hidden = !hasMockState

if (hasMockState) {
const rangeLabel = rangeLabels[currentLoadRange]
const isError = mockLoadState === "error"
loadChartState.classList.toggle("is-error", isError)
loadChartState.querySelector(".load-chart-state-icon").textContent = isError ? "!" : "i"
loadChartStateTitle.textContent = isError
? `${rangeLabel}負載資料暫時無法取得`
: `${rangeLabel}沒有可顯示的負載資料`
loadChartStateDescription.textContent = isError
? "保留目前選擇,不自動切換範圍;可等待下一次一般資料更新或手動刷新。"
: "此狀態不代表 0 kW;目前功率仍依即時連線與自身資料狀態獨立顯示。"
loadCanvas.setAttribute("aria-hidden", "true")
loadCanvas.removeAttribute("title")
return
}

loadChartState.classList.remove("is-error")
loadCanvas.removeAttribute("aria-hidden")
loadCanvas.setAttribute("aria-label", series.ariaLabel)
loadCanvas.title = series.tooltipExample
drawLineChart(loadCanvas, series)
}

function renderCharts() {
renderLoadChart()
drawBarChart(usageCanvas, usageSeries[currentUsageMode], currentUsageMode)
}

function syncLoadRangeButtons() {
document.querySelectorAll("[data-load-range]").forEach((button) => {
const isSelected = button.dataset.loadRange === currentLoadRange
button.classList.toggle("active", isSelected)
button.setAttribute("aria-pressed", String(isSelected))
})
}

syncLoadRangeButtons()

document.querySelectorAll("[data-load-range]").forEach((button) => {
button.addEventListener("click", () => {
currentLoadRange = button.dataset.loadRange
syncLoadRangeButtons()
renderLoadChart()
})
})

document.querySelectorAll("[data-usage-mode]").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll("[data-usage-mode]").forEach((item) => item.classList.remove("active"))
button.classList.add("active")
currentUsageMode = button.dataset.usageMode
drawBarChart(usageCanvas, usageSeries[currentUsageMode], currentUsageMode)
})
})

document.querySelectorAll(".connector").forEach((item) => {
const openDetail = () => {
document.getElementById("detailTitle").textContent = item.dataset.id
document.getElementById("detailText").textContent = item.dataset.detail
document.getElementById("connectorDetail").classList.add("open")
}
item.addEventListener("click", openDetail)
item.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
openDetail()
}
})
})

const connectorListBackdrop = document.getElementById("connectorListBackdrop")
const connectorListDrawer = document.getElementById("connectorListDrawer")
const openConnectorListButton = document.getElementById("openConnectorList")
const closeConnectorListButton = document.getElementById("closeConnectorList")
const connectorListSearch = document.getElementById("connectorListSearch")
const connectorListStatus = document.getElementById("connectorListStatus")
const connectorListGroup = document.getElementById("connectorListGroup")
const connectorFullList = document.getElementById("connectorFullList")
const connectorListPagination = document.getElementById("connectorListPagination")
const connectorListPreviousPage = document.getElementById("connectorListPreviousPage")
const connectorListNextPage = document.getElementById("connectorListNextPage")
const connectorListPageSize = 20

// Generate enough fake inventory to review the 30- and 100-Connector pagination fixtures.
function appendConnectorListFixtureRows(total) {
const existingCount = connectorFullList.querySelectorAll(".connector-list-row").length
const statusFixtures = [
{ status: "AVAILABLE", bucket: "AVAILABLE", label: "AVAILABLE" },
{ status: "CHARGING", bucket: "CHARGING", label: "CHARGING・7.20 kW" },
{ status: "WAITING", bucket: "WAITING", label: "AVAILABLE・QUEUE" },
{ status: "FAULTED", bucket: "NEEDS_ATTENTION", label: "FAULTED・OtherError", abnormal: true },
{ status: "BLOCKED", bucket: "NEEDS_ATTENTION", label: "AVAILABLE・BLOCKED", abnormal: true },
]

for (let index = existingCount; index < total; index += 1) {
const number = index + 1
const group = `B${(index % 3) + 1}`
const connectorId = `${group}-${String(number).padStart(2, "0")}`
const chargePointId = `CP-${String(Math.ceil(number / 2)).padStart(2, "0")}`
const fixture = statusFixtures[index % statusFixtures.length]
const row = document.createElement("div")
row.className = `connector-list-row${fixture.abnormal ? " is-abnormal" : ""}`
row.setAttribute("role", "listitem")
row.dataset.search = `${connectorId} ${chargePointId} 私人車位 cloudlink`.toLocaleLowerCase("zh-Hant")
row.dataset.status = fixture.status
row.dataset.group = group
row.dataset.displayBucket = fixture.bucket
row.innerHTML = `<strong>${connectorId}</strong><span class="row-cp">${chargePointId}</span><span class="row-area">私人車位</span><span class="row-state">${fixture.label}</span><a class="row-detail" href="#connectors" data-production-href="/connector/list">查看 →</a>`
connectorFullList.appendChild(row)
}
}

appendConnectorListFixtureRows(connectorPreviewPayload.total)
const connectorListRows = Array.from(connectorFullList.querySelectorAll(".connector-list-row"))
// UI-DEC-020: the Drawer has no sorting control. Filtered results retain this fixed Backend-style priority order.
const connectorListPriorityRank = {
FAULTED: 2,
BLOCKED: 3,
SUSPENDED_EVSE: 4,
UNAVAILABLE: 4,
SUSPENDED_EV: 5,
CHARGING: 6,
WAITING: 7,
AVAILABLE: 8,
}
const connectorListFixtureRows = connectorListRows
.slice(0, connectorPreviewPayload.total)
.sort((left, right) => {
const leftRank = left.dataset.displayBucket === "OTHER" ? 9 : (connectorListPriorityRank[left.dataset.status] ?? 9)
const rightRank = right.dataset.displayBucket === "OTHER" ? 9 : (connectorListPriorityRank[right.dataset.status] ?? 9)
if (leftRank !== rightRank) return leftRank - rightRank
const leftId = left.querySelector("strong")?.textContent ?? ""
const rightId = right.querySelector("strong")?.textContent ?? ""
return leftId.localeCompare(rightId, undefined, { numeric: true })
})
connectorListFixtureRows.forEach((row) => connectorFullList.appendChild(row))
// UI-DEC-018: keep Drawer controls mounted across close/open; a document reload naturally restores defaults.
let connectorDrawerReturnFocus = null
let connectorListCurrentPage = 1

document.getElementById("connector-list-title").textContent = `全部 ${connectorPreviewPayload.total} 個 Connector`

// UI-DEC-019: filter the complete fake inventory first, then render a fixed 20-item page.
function renderConnectorList(resetPage = false) {
if (resetPage) connectorListCurrentPage = 1
const query = connectorListSearch.value.trim().toLocaleLowerCase("zh-Hant")
const status = connectorListStatus.value
const group = connectorListGroup.value

const matchingRows = connectorListFixtureRows.filter((row) => {
const matchesQuery = query === "" || row.dataset.search.includes(query)
const matchesStatus = status === "" || row.dataset.status === status
const matchesGroup = group === "" || row.dataset.group === group
return matchesQuery && matchesStatus && matchesGroup
})

const totalPages = Math.max(1, Math.ceil(matchingRows.length / connectorListPageSize))
connectorListCurrentPage = Math.min(connectorListCurrentPage, totalPages)
const pageStart = (connectorListCurrentPage - 1) * connectorListPageSize
const visibleRows = new Set(matchingRows.slice(pageStart, pageStart + connectorListPageSize))
connectorListRows.forEach((row) => {
row.hidden = !visibleRows.has(row)
})

const hasMultiplePages = matchingRows.length > connectorListPageSize
connectorListPagination.hidden = !hasMultiplePages
connectorListPreviousPage.disabled = connectorListCurrentPage <= 1
connectorListNextPage.disabled = connectorListCurrentPage >= totalPages
document.getElementById("connectorListPageSummary").textContent = hasMultiplePages
? `第 ${connectorListCurrentPage} / ${totalPages} 頁・共 ${matchingRows.length} 個`
: `共 ${matchingRows.length} 個`
document.getElementById("connectorListEmpty").hidden = matchingRows.length !== 0
}

function openConnectorList() {
connectorDrawerReturnFocus = document.activeElement
connectorListBackdrop.hidden = false
document.body.classList.add("has-open-drawer")
openConnectorListButton.setAttribute("aria-expanded", "true")
requestAnimationFrame(() => connectorListSearch.focus())
}

function closeConnectorList() {
connectorListBackdrop.hidden = true
document.body.classList.remove("has-open-drawer")
openConnectorListButton.setAttribute("aria-expanded", "false")
if (connectorDrawerReturnFocus instanceof HTMLElement) connectorDrawerReturnFocus.focus()
}

openConnectorListButton.addEventListener("click", openConnectorList)
closeConnectorListButton.addEventListener("click", closeConnectorList)
connectorListBackdrop.addEventListener("click", (event) => {
if (event.target === connectorListBackdrop) closeConnectorList()
})
connectorListSearch.addEventListener("input", () => renderConnectorList(true))
connectorListStatus.addEventListener("change", () => renderConnectorList(true))
connectorListGroup.addEventListener("change", () => renderConnectorList(true))
connectorListPreviousPage.addEventListener("click", () => {
if (connectorListCurrentPage <= 1) return
connectorListCurrentPage -= 1
renderConnectorList()
})
connectorListNextPage.addEventListener("click", () => {
connectorListCurrentPage += 1
renderConnectorList()
})
renderConnectorList()
connectorListDrawer.querySelectorAll("a[href='#connectors']").forEach((link) => {
link.addEventListener("click", closeConnectorList)
})
connectorListDrawer.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault()
closeConnectorList()
return
}
if (event.key !== "Tab") return
const focusable = Array.from(
connectorListDrawer.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), a[href]'),
).filter((element) => !element.closest("[hidden]"))
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
})

document.getElementById("refreshBtn").addEventListener("click", (event) => {
const button = event.currentTarget
button.classList.remove("spin")
void button.offsetWidth
button.classList.add("spin")
const now = new Date()
document.getElementById("updatedAt").textContent =
`一般資料剛剛更新・${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`
})

let resizeTimer
window.addEventListener("resize", () => {
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
syncConnectorPreview()
renderCharts()
}, 120)
})

syncConnectorPreview()
requestAnimationFrame(() => requestAnimationFrame(renderCharts))
</script>
</body>
</html>
(6-6/9)