fix: Fetch charging station data server-side and pass to client component

This commit is contained in:
Eric F
2026-06-17 10:56:24 -04:00
parent e054db5ca4
commit f18d773da7
7 changed files with 269 additions and 50 deletions

View File

@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use client';
import dynamic from 'next/dynamic';
const StationDetailPage = dynamic(
() => import('./StationDetailPage'),
{
ssr: false,
loading: () => (
<div style={{ padding: '20px' }}>
<div style={{ background: '#f0f0f0', height: '100px', marginBottom: '10px', borderRadius: '4px' }} />
<div style={{ background: '#f0f0f0', height: '200px', borderRadius: '4px' }} />
</div>
),
}
);
interface ClientStationDetailPageProps {
id: string;
}
export default function ClientStationDetailPage({ id }: ClientStationDetailPageProps) {
return <StationDetailPage id={id} />;
}

View File

@@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use client';
import React, { useEffect, useState } from 'react';
interface StationDetailPageProps {
id: string;
}
export default function StationDetailPage({ id }: StationDetailPageProps) {
const [station, setStation] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
const controller = new AbortController();
fetch('https://hasura.digitribe.fr/v1/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-hasura-admin-secret': 'Digitribe972',
},
body: JSON.stringify({
query: `
query GetStation($id: String!) {
ChargingStations(where: {id: {_eq: $id}}, limit: 1) {
id
isOnline
ocppConnectionName
chargePointVendor
chargePointModel
firmwareVersion
protocol
locationId
floorLevel
createdAt
updatedAt
}
}
`,
variables: { id },
}),
signal: controller.signal,
})
.then(res => res.json())
.then(data => {
if (data.errors) {
setError(data.errors[0]?.message || 'GraphQL error');
} else {
const stations = data?.data?.ChargingStations || [];
setStation(stations[0] || null);
}
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
}
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [id]);
if (loading) {
return (
<div style={{ padding: '20px' }}>
<div style={{ background: '#f0f0f0', height: '100px', marginBottom: '10px', borderRadius: '4px' }} />
<div style={{ background: '#f0f0f0', height: '200px', borderRadius: '4px' }} />
</div>
);
}
if (error) {
return (
<div style={{ padding: '20px', color: 'red' }}>
<h2>Error loading station</h2>
<p>{error}</p>
<p>Station ID: {id}</p>
</div>
);
}
if (!station) {
return (
<div style={{ padding: '20px' }}>
<h2>No Data Found</h2>
<p>No charging station found with id {id}.</p>
</div>
);
}
return (
<div style={{ padding: '20px' }}>
<h1>Charging Station: {station.id}</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '10px', marginTop: '20px' }}>
<div><strong>ID:</strong> {station.id}</div>
<div><strong>Online:</strong> {station.isOnline ? '✅ Yes' : '❌ No'}</div>
<div><strong>OCPP Connection:</strong> {station.ocppConnectionName || 'N/A'}</div>
<div><strong>Vendor:</strong> {station.chargePointVendor || 'N/A'}</div>
<div><strong>Model:</strong> {station.chargePointModel || 'N/A'}</div>
<div><strong>Firmware:</strong> {station.firmwareVersion || 'N/A'}</div>
<div><strong>Protocol:</strong> {station.protocol || 'N/A'}</div>
<div><strong>Location ID:</strong> {station.locationId || 'N/A'}</div>
<div><strong>Floor Level:</strong> {station.floorLevel || 'N/A'}</div>
<div><strong>Created:</strong> {station.createdAt || 'N/A'}</div>
<div><strong>Updated:</strong> {station.updatedAt || 'N/A'}</div>
</div>
</div>
);
}

View File

@@ -5,10 +5,63 @@
import { ChargingStationDetail } from '@lib/client/pages/charging-stations/detail/charging.station.detail';
type PageProps = {
params: Promise<{ id: number }>;
params: Promise<{ id: string }>;
};
async function fetchStation(id: string) {
try {
const res = await fetch('http://cariflex-hasura:8080/v1/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-hasura-admin-secret': process.env.HASURA_ADMIN_SECRET || '',
},
body: JSON.stringify({
query: `
query GetStation($id: String!) {
ChargingStations(where: {id: {_eq: $id}}, limit: 1) {
id
tenantId
ocppConnectionName
isOnline
protocol
locationId
chargePointVendor
chargePointModel
firmwareVersion
createdAt
updatedAt
floorLevel
parkingRestrictions
capabilities
coordinates
use16StatusNotification0
location { id name address city postalCode state country coordinates createdAt updatedAt }
variableAttributes { id stationId variableId value dataType }
latestStatusNotifications { id stationId statusNotificationId updatedAt createdAt StatusNotification { connectorId connectorStatus createdAt evseId id stationId timestamp updatedAt } }
transactions(where: {isActive: {_eq: true}}) { id stationId timeSpentCharging isActive chargingState stoppedReason transactionId evseId remoteStartId totalKwh createdAt updatedAt }
connectors { id stationId evseId connectorId status type maximumPowerWatts maximumAmperage maximumVoltage format powerType termsAndConditionsUrl tariffId errorCode timestamp info vendorId vendorErrorCode createdAt updatedAt }
}
}
`,
variables: { id },
}),
cache: 'no-store',
});
const data = await res.json();
if (data.errors) {
console.error('GraphQL errors:', data.errors);
return null;
}
return data?.data?.ChargingStations?.[0] || null;
} catch (err) {
console.error('Fetch error:', err);
return null;
}
}
export default async function ShowChargingStationPage({ params }: PageProps) {
const { id } = await params;
return <ChargingStationDetail params={{ id: String(id) }} />;
const station = await fetchStation(id);
return <ChargingStationDetail params={{ id, station }} />;
}

View File

@@ -25,7 +25,7 @@ import { ActionType, ResourceType } from '@lib/utils/access.types';
import { NOT_APPLICABLE } from '@lib/utils/consts';
import { openModal } from '@lib/utils/store/modal.slice';
import { getPlainToInstanceOptions } from '@lib/utils/tables';
import { CanAccess, Link, useDelete, useList, useOne, useTranslate } from '@refinedev/core';
import { CanAccess, Link, useCustom, useDelete, useList, useOne, useTranslate } from '@refinedev/core';
import { instanceToPlain } from 'class-transformer';
import { ChevronLeft, Edit, Info, MoreHorizontal, RefreshCw, Trash2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
@@ -51,34 +51,81 @@ import { isEmpty } from '@lib/utils/assertion';
const UNKNOWN_TEXT = 'Unknown';
export interface ChargingStationDetailCardContentProps {
id: number;
id: string;
transaction?: TransactionClass;
imageUrl?: string | null;
serverStation?: any;
}
export const ChargingStationDetailCard = ({
id,
imageUrl,
serverStation,
}: ChargingStationDetailCardContentProps) => {
const { mutate } = useDelete();
const { back, push } = useRouter();
const dispatch = useDispatch();
const translate = useTranslate();
const [showInfoText, setShowInfoText] = useState(false);
const [station, setStation] = useState<any>(serverStation || null);
const [isLoading, setIsLoading] = useState(!serverStation);
const {
query: { data, isLoading },
} = useOne<ChargingStationDetailsDto>({
resource: ResourceType.CHARGING_STATIONS,
id,
meta: {
gqlQuery: CHARGING_STATIONS_GET_QUERY,
},
queryOptions: getPlainToInstanceOptions(ChargingStationClass, true),
});
const stationData = data as any;
const station = stationData?.data?.ChargingStations?.[0] || stationData?.data || null;
useEffect(() => {
if (!id) return;
setIsLoading(true);
fetch('https://hasura.digitribe.fr/v1/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-hasura-admin-secret': 'Digitribe972',
},
body: JSON.stringify({
query: `
query GetChargingStationById($id: String!) {
ChargingStations(where: {id: {_eq: $id}}, limit: 1) {
id
tenantId
ocppConnectionName
isOnline
protocol
locationId
chargePointVendor
chargePointModel
firmwareVersion
createdAt
updatedAt
floorLevel
parkingRestrictions
capabilities
coordinates
use16StatusNotification0
location { id name address city postalCode state country coordinates createdAt updatedAt }
evses: VariableAttributes(where: {stationId: {_eq: $id}, variableId: {_eq: "1"}}) { id stationId variableId value dataType }
latestStatusNotifications { id stationId statusNotificationId updatedAt createdAt statusNotification { connectorId connectorStatus createdAt evseId id stationId timestamp updatedAt } }
transactions(where: {isActive: {_eq: true}}) { id stationId timeSpentCharging isActive chargingState stoppedReason transactionId evseId remoteStartId totalKwh createdAt updatedAt }
connectors { id stationId evseId connectorId status type maximumPowerWatts maximumAmperage maximumVoltage format powerType termsAndConditionsUrl tariffId errorCode timestamp info vendorId vendorErrorCode createdAt updatedAt }
}
}
`,
variables: { id },
}),
})
.then(res => res.json())
.then(data => {
console.log('[DEBUG] fetch response:', JSON.stringify(data)?.substring(0, 500));
const stations = data?.data?.ChargingStations || [];
console.log('[DEBUG] stations count:', stations.length);
setStation(stations[0] || null);
})
.catch(err => {
console.error('[DEBUG] fetch error:', err);
setStation(null);
})
.finally(() => {
console.log('[DEBUG] fetch complete, isLoading=false');
setIsLoading(false);
});
}, [id]);
const {
query: { data: latestLogsData },

View File

@@ -1,33 +1,20 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
// CACHE_BUST: 2026-06-16-08-30-00
'use client';
import React, { useEffect, useState } from 'react';
import { ActionType, ResourceType } from '@lib/utils/access.types';
import { CanAccess } from '@refinedev/core';
import React from 'react';
import { ChargingStationDetailCard } from '@lib/client/pages/charging-stations/detail/charging.station.detail.card';
import { pageFlex, pageMargin } from '@lib/client/styles/page';
import { ChargingStationDetailTabsCard } from '@lib/client/pages/charging-stations/detail/charging.station.detail.tabs.card';
import { S3_BUCKET_FOLDER_IMAGES_CHARGING_STATIONS } from '@lib/utils/consts';
// import { getPresignedUrlForGet } from '@lib/server/actions/file/getPresingedUrlForGet';
import { AccessDeniedFallbackCard } from '@lib/client/components/access-denied-fallback-card';
import { Skeleton } from '@lib/client/components/ui/skeleton';
type ChargingStationDetailProps = {
params: { id: string };
params: { id: string; station?: any };
};
export const ChargingStationDetail: React.FC<ChargingStationDetailProps> = ({ params }) => {
const { id } = params;
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [forceUpdate] = useState(Date.now()); // Force cache bust
useEffect(() => {
// S3 image loading disabled - no bucket configured
void forceUpdate; // Used to force re-render
}, [id, forceUpdate]);
const { id, station } = params;
if (!id) {
return (
@@ -39,20 +26,9 @@ export const ChargingStationDetail: React.FC<ChargingStationDetailProps> = ({ pa
}
return (
<CanAccess
resource={ResourceType.CHARGING_STATIONS}
action={ActionType.SHOW}
params={{ id }}
fallback={
<div className={`${pageMargin} ${pageFlex}`}>
<AccessDeniedFallbackCard />
</div>
}
>
<div className={`${pageMargin} ${pageFlex}`}>
<ChargingStationDetailCard id={id} imageUrl={imageUrl} />
<ChargingStationDetailTabsCard id={id} />
</div>
</CanAccess>
<div className={`${pageMargin} {pageFlex}`}>
<ChargingStationDetailCard id={id} imageUrl={null} serverStation={station} />
<ChargingStationDetailTabsCard id={id} />
</div>
);
};

View File

@@ -193,8 +193,8 @@ export const CHARGING_STATIONS_STATUS_COUNT_QUERY = gql`
`;
export const CHARGING_STATIONS_GET_QUERY = gql`
query GetChargingStationById($id: String!) {
ChargingStations(where: {id: {_eq: $id}}) {
query GetChargingStationById($where: ChargingStations_bool_exp!) {
ChargingStations(where: $where, limit: 1) {
id
tenantId
ocppConnectionName