All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m56s
224 lines
7.0 KiB
TypeScript
224 lines
7.0 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
import axios from 'axios';
|
|
|
|
type FnType = 'grid' | 'solar';
|
|
|
|
interface LoggingControlCardProps {
|
|
siteId: string;
|
|
projectLabel?: string; // nice display (e.g., CRM project_name)
|
|
className?: string;
|
|
}
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_FASTAPI_URL;
|
|
|
|
type FnState = {
|
|
serial: string;
|
|
isLogging: boolean;
|
|
isBusy: boolean; // to block double clicks while calling API
|
|
error?: string | null;
|
|
};
|
|
|
|
const emptyFnState: FnState = { serial: '', isLogging: false, isBusy: false, error: null };
|
|
|
|
const storageKey = (siteId: string) => `logging_control_${siteId}`;
|
|
|
|
export default function LoggingControlCard({
|
|
siteId,
|
|
projectLabel,
|
|
className = '',
|
|
}: LoggingControlCardProps) {
|
|
const [grid, setGrid] = useState<FnState>(emptyFnState);
|
|
const [solar, setSolar] = useState<FnState>(emptyFnState);
|
|
|
|
// Load persisted state (if any)
|
|
useEffect(() => {
|
|
try {
|
|
const raw = localStorage.getItem(storageKey(siteId));
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw);
|
|
setGrid({ ...emptyFnState, ...(parsed.grid ?? {}) });
|
|
setSolar({ ...emptyFnState, ...(parsed.solar ?? {}) });
|
|
} else {
|
|
setGrid(emptyFnState);
|
|
setSolar(emptyFnState);
|
|
}
|
|
} catch {
|
|
setGrid(emptyFnState);
|
|
setSolar(emptyFnState);
|
|
}
|
|
}, [siteId]);
|
|
|
|
// Persist on any change
|
|
useEffect(() => {
|
|
const data = { grid, solar };
|
|
try {
|
|
localStorage.setItem(storageKey(siteId), JSON.stringify(data));
|
|
} catch {
|
|
// ignore storage errors
|
|
}
|
|
}, [siteId, grid, solar]);
|
|
|
|
const title = useMemo(
|
|
() => `Logging Control${projectLabel ? ` — ${projectLabel}` : ''}`,
|
|
[projectLabel]
|
|
);
|
|
|
|
const topicsFor = (fn: FnType, serial: string) => {
|
|
return [`ADW300/${siteId}/${serial}/${fn}`];
|
|
};
|
|
|
|
const start = async (fn: FnType) => {
|
|
const state = fn === 'grid' ? grid : solar;
|
|
const setState = fn === 'grid' ? setGrid : setSolar;
|
|
|
|
if (!state.serial.trim()) {
|
|
setState((s) => ({ ...s, error: 'Please enter a meter serial number.' }));
|
|
return;
|
|
}
|
|
|
|
setState((s) => ({ ...s, isBusy: true, error: null }));
|
|
|
|
try {
|
|
const topics = topicsFor(fn, state.serial.trim());
|
|
const res = await axios.post(`${API_URL}/start-logging`, { topics });
|
|
console.log('Start logging:', res.data);
|
|
setState((s) => ({ ...s, isLogging: true, isBusy: false }));
|
|
} catch (e: any) {
|
|
console.error('Failed to start logging', e);
|
|
setState((s) => ({
|
|
...s,
|
|
isBusy: false,
|
|
error: e?.response?.data?.detail || e?.message || 'Failed to start logging',
|
|
}));
|
|
}
|
|
};
|
|
|
|
const stop = async (fn: FnType) => {
|
|
const state = fn === 'grid' ? grid : solar;
|
|
const setState = fn === 'grid' ? setGrid : setSolar;
|
|
|
|
if (!state.isLogging) return;
|
|
|
|
const confirmed = window.confirm(
|
|
`Stop logging for ${fn.toUpperCase()} meter "${state.serial}" at site ${siteId}?`
|
|
);
|
|
if (!confirmed) return;
|
|
|
|
setState((s) => ({ ...s, isBusy: true, error: null }));
|
|
|
|
try {
|
|
const topics = topicsFor(fn, state.serial.trim());
|
|
const res = await axios.post(`${API_URL}/stop-logging`, { topics });
|
|
console.log('Stop logging:', res.data);
|
|
setState((s) => ({ ...s, isLogging: false, isBusy: false }));
|
|
} catch (e: any) {
|
|
console.error('Failed to stop logging', e);
|
|
setState((s) => ({
|
|
...s,
|
|
isBusy: false,
|
|
error: e?.response?.data?.detail || e?.message || 'Failed to stop logging',
|
|
}));
|
|
}
|
|
};
|
|
|
|
// Responsive utility classes
|
|
const field =
|
|
'w-full px-3 py-2 sm:py-2.5 border rounded-md text-sm sm:text-base placeholder:text-gray-400 dark:border-rtgray-700 dark:bg-rtgray-700 dark:text-white';
|
|
|
|
const label =
|
|
'text-gray-600 dark:text-white/85 font-medium text-sm sm:text-base mb-1 flex items-center justify-between mr-2.5';
|
|
|
|
const section = (
|
|
fn: FnType,
|
|
labelText: string,
|
|
state: FnState,
|
|
setState: React.Dispatch<React.SetStateAction<FnState>>
|
|
) => (
|
|
<div className="space-y-2">
|
|
<div className={label}>
|
|
<span>{labelText}</span>
|
|
{state.isLogging && (
|
|
<span
|
|
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] sm:text-xs font-semibold
|
|
bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300"
|
|
aria-live="polite"
|
|
>
|
|
Logging
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input + Button: stack on mobile, row on ≥sm */}
|
|
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
|
|
<input
|
|
type="text"
|
|
autoComplete="off"
|
|
inputMode="text"
|
|
placeholder="Meter serial number"
|
|
className={`${field} flex-1`}
|
|
value={state.serial}
|
|
onChange={(e) => setState((s) => ({ ...s, serial: e.target.value }))}
|
|
disabled={state.isLogging || state.isBusy}
|
|
aria-label={`${labelText} serial number`}
|
|
/>
|
|
|
|
{!state.isLogging ? (
|
|
<button
|
|
onClick={() => start(fn)}
|
|
disabled={state.isBusy || !state.serial.trim()}
|
|
className={`h-10 sm:h-11 rounded-full font-medium transition
|
|
w-full sm:w-auto sm:min-w-28 mt-2 lg:mt-0
|
|
${state.isBusy || !state.serial.trim()
|
|
? 'bg-gray-400 cursor-not-allowed text-black/70'
|
|
: 'bg-rtyellow-200 hover:bg-rtyellow-300 text-black'}`}
|
|
aria-disabled={state.isBusy || !state.serial.trim()}
|
|
>
|
|
{state.isBusy ? 'Starting…' : 'Start'}
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => stop(fn)}
|
|
disabled={state.isBusy}
|
|
className={`h-10 sm:h-11 rounded-full font-medium transition
|
|
w-full sm:w-auto sm:min-w-28 mt-2 lg:mt-0
|
|
${state.isBusy ? 'bg-gray-400 cursor-not-allowed' : 'bg-red-500 hover:bg-red-600'} text-white`}
|
|
aria-disabled={state.isBusy}
|
|
>
|
|
{state.isBusy ? 'Stopping…' : 'Stop'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{!!state.error && <div className="text-sm sm:text-[15px] text-red-600">{state.error}</div>}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={`bg-white p-4 sm:p-5 md:p-6 rounded-xl md:rounded-2xl shadow-md space-y-4 md:space-y-5
|
|
dark:bg-rtgray-800 dark:text-white-light w-full ${className}`}
|
|
>
|
|
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold truncate" title={title}>
|
|
{title}
|
|
</h2>
|
|
|
|
{section('grid', 'Grid Meter', grid, setGrid)}
|
|
<div className="border-t dark:border-rtgray-700" />
|
|
{section('solar', 'Solar Meter', solar, setSolar)}
|
|
|
|
<div className="text-[11px] sm:text-xs text-gray-500 dark:text-gray-400 pt-2 leading-relaxed break-words">
|
|
• Inputs lock while logging is active. Stop to edit the serial.
|
|
<br />
|
|
• Topics follow{' '}
|
|
<code className="break-all">
|
|
ADW300/{'{'}siteId{'}'}/{'{'}serial{'}'}/(grid|solar)
|
|
</code>
|
|
.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|