Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter
- CitrineOS core extracted (CSMS OCPP 2.0.1) - OpenOCPP extracted (firmware OCPP 1.6J/2.0.1) - ShapeShifter library installed (pip install -e) - ShapeShifter specification extracted - EVerest extracted TODO updated with progress
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { AuthorizationUpsert } from '@lib/client/pages/authorizations/upsert/authorization.upsert';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function EditAuthorizationPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <AuthorizationUpsert params={{ id }} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { AuthorizationDetail } from '@lib/client/pages/authorizations/detail/authorization.detail';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function ShowAuthorizationPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <AuthorizationDetail params={{ id }} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { AuthorizationUpsert } from '@lib/client/pages/authorizations/upsert/authorization.upsert';
|
||||
|
||||
export default function NewAuthorizationPage() {
|
||||
return <AuthorizationUpsert params={{}} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { AuthorizationsList } from '@lib/client/pages/authorizations/list/authorizations.list';
|
||||
|
||||
export default function ListAuthorizationPage() {
|
||||
return <AuthorizationsList />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { ChargingStationUpsert } from '@lib/client/pages/charging-stations/upsert/charging.stations.upsert';
|
||||
import config from '@lib/utils/config';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function EditChargingStationPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return (
|
||||
<ChargingStationUpsert params={{ id: Number(id) }} allowImageUpload={config.allowImageUpload} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { ChargingStationDetail } from '@lib/client/pages/charging-stations/detail/charging.station.detail';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: number }>;
|
||||
};
|
||||
|
||||
export default async function ShowChargingStationPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <ChargingStationDetail params={{ id: Number(id) }} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { ChargingStationUpsert } from '@lib/client/pages/charging-stations/upsert/charging.stations.upsert';
|
||||
import config from '@lib/utils/config';
|
||||
|
||||
export default function NewChargingStationPage() {
|
||||
return <ChargingStationUpsert params={{}} allowImageUpload={config.allowImageUpload} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { ChargingStationsList } from '@lib/client/pages/charging-stations/list/charging.stations.list';
|
||||
|
||||
export default function ListChargingStationPage() {
|
||||
return <ChargingStationsList />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import AuthenticatedLayout from '@lib/client/components/authenticated-layout';
|
||||
import React from 'react';
|
||||
|
||||
export default async function Layout({ children }: React.PropsWithChildren) {
|
||||
return <AuthenticatedLayout authKey="authenticated">{children}</AuthenticatedLayout>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { LocationsUpsert } from '@lib/client/pages/locations/upsert/locations.upsert';
|
||||
import config from '@lib/utils/config';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function EditLocationPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <LocationsUpsert params={{ id }} allowImageUpload={config.allowImageUpload} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { LocationsDetail } from '@lib/client/pages/locations/detail/locations.detail';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function ShowLocationPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <LocationsDetail params={{ id }} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { LocationsUpsert } from '@lib/client/pages/locations/upsert/locations.upsert';
|
||||
import config from '@lib/utils/config';
|
||||
|
||||
export default function NewLocationPage() {
|
||||
return <LocationsUpsert params={{}} allowImageUpload={config.allowImageUpload} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { LocationsList } from '@lib/client/pages/locations/list/locations.list';
|
||||
|
||||
export default function ListLocationPage() {
|
||||
return <LocationsList />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Overview } from '@lib/client/pages/overview';
|
||||
|
||||
export default function OverviewPage() {
|
||||
return <Overview />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { PartnersUpsert } from '@lib/client/pages/partners/upsert/partners.upsert';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function EditPartnerPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <PartnersUpsert params={{ id }} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { PartnersDetail } from '@lib/client/pages/partners/detail/partners.detail';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function ShowPartnerPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <PartnersDetail params={{ id }} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { PartnersUpsert } from '@lib/client/pages/partners/upsert/partners.upsert';
|
||||
|
||||
export default function NewPartnerPage() {
|
||||
return <PartnersUpsert params={{}} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { PartnersList } from '@lib/client/pages/partners/list/partners.list';
|
||||
|
||||
export default function ListPartnerPage() {
|
||||
return <PartnersList />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { TariffUpsert } from '@lib/client/pages/tariffs/upsert/tariff.upsert';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function EditTariffPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <TariffUpsert params={{ id }} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { TariffDetail } from '@lib/client/pages/tariffs/detail/tariff.detail';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function ShowTariffPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <TariffDetail params={{ id }} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { TariffUpsert } from '@lib/client/pages/tariffs/upsert/tariff.upsert';
|
||||
|
||||
export default function CreateTariffPage() {
|
||||
return <TariffUpsert params={{}} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { TariffsList } from '@lib/client/pages/tariffs/list/tariffs.list';
|
||||
|
||||
export default function ListTariffPage() {
|
||||
return <TariffsList />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { TransactionDetail } from '@lib/client/pages/transactions/detail/transaction.detail';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function ShowTransactionPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <TransactionDetail params={{ id }} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { TransactionsList } from '@lib/client/pages/transactions/list/transactions.list';
|
||||
|
||||
export default function ListTransactionPage() {
|
||||
return <TransactionsList />;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,217 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/**
|
||||
* NextAuth configuration for authentication
|
||||
*
|
||||
* Keycloak Token Refresh:
|
||||
* - Access tokens are automatically refreshed 60 seconds before expiration
|
||||
* - Refresh tokens are used to obtain new access tokens without re-authentication
|
||||
* - If token refresh fails, the user will be logged out and redirected to login
|
||||
*
|
||||
* Changing JWT Token TTL in Keycloak:
|
||||
* To change the access token lifespan, you need to configure it in the Keycloak Admin Console:
|
||||
*
|
||||
* 1. Log in to the Keycloak Admin Console
|
||||
* 2. Select your realm (e.g., CitrineOS realm)
|
||||
* 3. Navigate to: Realm Settings → Sessions tab
|
||||
* 4. Configure the following settings:
|
||||
* - SSO Session Idle: How long a session can be idle before requiring re-authentication
|
||||
* * Recommended: 30 minutes or more to keep users logged in while active
|
||||
* - SSO Session Max: Maximum session lifespan regardless of activity
|
||||
* * Recommended: 10-12 hours for full work day sessions
|
||||
* 3. Navigate to: Realm Settings → Tokens tab
|
||||
* 4. Configure the following settings:
|
||||
* - Access Token Lifespan: How long access tokens are valid (default is often 5 minutes)
|
||||
* * Recommended: 5-15 minutes for production
|
||||
* * Longer lifespans reduce refresh calls but increase security risk
|
||||
* * Should be short relative to SSO Session Idle
|
||||
* 5. Click "Save" at the bottom of the page
|
||||
*
|
||||
* Note: Client-specific token lifespans can also be configured:
|
||||
* 1. Go to: Clients → Select your client (e.g., citrineos-ui)
|
||||
* 2. Navigate to: Advanced Settings → Advanced tab
|
||||
* 3. Configure client-specific token lifespans if needed
|
||||
*/
|
||||
|
||||
import type { AuthOptions } from 'next-auth';
|
||||
import KeycloakProvider from 'next-auth/providers/keycloak';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import config from '@lib/utils/config';
|
||||
import { parseJwt } from '@lib/utils/jwt';
|
||||
import { genericAdminUser } from '@lib/providers/auth-provider/generic-auth-provider';
|
||||
|
||||
const keycloakServerUrl = config.keycloakServerUrl || config.keycloakUrl;
|
||||
const authProvider = config.authProvider;
|
||||
|
||||
/**
|
||||
* Refreshes an expired access token using the refresh token
|
||||
*/
|
||||
async function refreshAccessToken(token: any) {
|
||||
if (authProvider === 'generic') {
|
||||
return token;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${keycloakServerUrl}/realms/${config.keycloakRealm}/protocol/openid-connect/token`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.keycloakClientId!,
|
||||
client_secret: config.keycloakClientSecret!,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: token.refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
const refreshedTokens = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw refreshedTokens;
|
||||
}
|
||||
|
||||
// Parse the new access token to get updated roles and tenant info
|
||||
const accessTokenParsed = parseJwt(refreshedTokens.access_token);
|
||||
|
||||
return {
|
||||
...token,
|
||||
accessToken: refreshedTokens.access_token,
|
||||
idToken: refreshedTokens.id_token,
|
||||
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
|
||||
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
|
||||
roles: accessTokenParsed.resource_access?.[config.keycloakClientId!]?.roles || [],
|
||||
tenantId: accessTokenParsed.tenant_id,
|
||||
error: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error refreshing access token:', error);
|
||||
return {
|
||||
...token,
|
||||
error: 'RefreshAccessTokenError',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defaults to Generic Auth Provider if Keycloak is not configured.
|
||||
*/
|
||||
const getProvider = () => {
|
||||
if (authProvider === 'keycloak') {
|
||||
return KeycloakProvider({
|
||||
clientId: config.keycloakClientId!,
|
||||
clientSecret: config.keycloakClientSecret!,
|
||||
wellKnown: undefined,
|
||||
issuer: `${config.keycloakUrl}/realms/${config.keycloakRealm}`,
|
||||
authorization: {
|
||||
url: `${config.keycloakUrl}/realms/${config.keycloakRealm}/protocol/openid-connect/auth`,
|
||||
},
|
||||
token: `${keycloakServerUrl}/realms/${config.keycloakRealm}/protocol/openid-connect/token`,
|
||||
userinfo: `${keycloakServerUrl}/realms/${config.keycloakRealm}/protocol/openid-connect/userinfo`,
|
||||
jwks_endpoint: `${keycloakServerUrl}/realms/${config.keycloakRealm}/protocol/openid-connect/certs`,
|
||||
});
|
||||
} else {
|
||||
return CredentialsProvider({
|
||||
id: 'generic',
|
||||
credentials: {
|
||||
username: { label: 'Username', type: 'text' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
if (
|
||||
credentials &&
|
||||
credentials.username === config.adminEmail &&
|
||||
credentials.password === config.adminPassword
|
||||
) {
|
||||
return genericAdminUser;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const authOptions: AuthOptions = {
|
||||
providers: [getProvider()],
|
||||
events: {},
|
||||
callbacks: {
|
||||
async redirect({ url, baseUrl }) {
|
||||
// Redirect to overview page after successful login
|
||||
// If the url is the callback from Keycloak or the signin page, redirect to overview
|
||||
if (url.startsWith(baseUrl)) {
|
||||
// Check if it's a callback or sign-in, redirect to overview
|
||||
if (url.includes('/api/auth/callback') || url.includes('/api/auth/signin')) {
|
||||
return `${baseUrl}/overview`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
// Allow relative callback URLs
|
||||
if (url.startsWith('/')) {
|
||||
return `${baseUrl}${url}`;
|
||||
}
|
||||
// Default to overview page for any other case
|
||||
return `${baseUrl}/overview`;
|
||||
},
|
||||
async jwt({ token, account }) {
|
||||
// Initial sign in - store Keycloak tokens in JWT
|
||||
if (account) {
|
||||
token.accessToken = account.access_token;
|
||||
token.idToken = account.id_token;
|
||||
token.refreshToken = account.refresh_token;
|
||||
token.accessTokenExpires = account.expires_at
|
||||
? account.expires_at * 1000
|
||||
: Date.now() + 300000; // Default to 5 minutes if not provided
|
||||
|
||||
// Store the Keycloak end-session URL so the client can perform a proper
|
||||
// browser-level logout (server has access to realm name, client does not)
|
||||
if (authProvider === 'keycloak') {
|
||||
token.keycloakLogoutUrl = `${config.keycloakUrl}/realms/${config.keycloakRealm}/protocol/openid-connect/logout`;
|
||||
}
|
||||
|
||||
// Parse access token to get roles
|
||||
if (account.access_token) {
|
||||
const accessTokenParsed = parseJwt(account.access_token as string);
|
||||
// Extract client roles from resource_access
|
||||
token.roles = accessTokenParsed.resource_access?.[config.keycloakClientId!]?.roles || [];
|
||||
// Extract tenant_id
|
||||
token.tenantId = accessTokenParsed.tenant_id;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// Return previous token if the access token has not expired yet
|
||||
// Add a 60 second buffer to refresh before actual expiration
|
||||
if (Date.now() < (token.accessTokenExpires as number) - 60000) {
|
||||
return token;
|
||||
}
|
||||
|
||||
// Access token has expired, try to refresh it
|
||||
return refreshAccessToken(token);
|
||||
},
|
||||
async session({ session, token }) {
|
||||
// Pass JWT info to client session
|
||||
if (session.user) {
|
||||
(session.user as any).roles = token.roles;
|
||||
(session.user as any).tenantId = token.tenantId;
|
||||
}
|
||||
(session as any).accessToken = token.accessToken;
|
||||
(session as any).idToken = token.idToken;
|
||||
(session as any).keycloakLogoutUrl = token.keycloakLogoutUrl;
|
||||
(session as any).error = token.error;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
},
|
||||
};
|
||||
|
||||
export default authOptions;
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import NextAuth from 'next-auth';
|
||||
import authOptions from '@app/api/auth/[...nextauth]/options';
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export async function GET() {
|
||||
return Response.json({ status: 'ok' });
|
||||
}
|
||||
108
tools/citrineos-core-main/apps/operator-ui/src/app/globals.css
Normal file
108
tools/citrineos-core-main/apps/operator-ui/src/app/globals.css
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
|
||||
@theme inline {
|
||||
/* Color tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: calc(var(--radius) - 2px);
|
||||
--radius-md: calc(var(--radius));
|
||||
--radius-lg: calc(var(--radius) + 2px);
|
||||
--radius-xl: calc(var(--radius) + 6px);
|
||||
|
||||
/* Font */
|
||||
--font-sans: var(--font-roobert), system-ui, sans-serif;
|
||||
|
||||
/* Non-themed Colors */
|
||||
--light-mode: #ffae0b;
|
||||
--light-mode-foreground: #442b00;
|
||||
--dark-mode: #d7dcff;
|
||||
--dark-mode-foreground: #000d37;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.375rem;
|
||||
|
||||
/* Light mode colors */
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: #131211;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: #131211;
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: #131211;
|
||||
--primary: #ffae0b;
|
||||
--primary-foreground: #6e4900;
|
||||
--secondary: #a5b2ff;
|
||||
--secondary-foreground: #000d37;
|
||||
--muted: #e7e6e4;
|
||||
--muted-foreground: #32302e;
|
||||
--accent: hsl(40, 5.88%, 95%);
|
||||
--accent-foreground: #6e87ff;
|
||||
--destructive: #f61631;
|
||||
--success: #3db014;
|
||||
--warning: #ff7300;
|
||||
--border: #e7e6e4;
|
||||
--input: #c3bdb9;
|
||||
--ring: #9c9793;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
/* Dark mode colors */
|
||||
--background: #16151e;
|
||||
--foreground: #f0f0f0;
|
||||
--card: #23242e;
|
||||
--card-foreground: #f0f0f0;
|
||||
--popover: #23242e;
|
||||
--popover-foreground: #f0f0f0;
|
||||
--primary: hsl(40.08, 100%, 60%);
|
||||
--primary-foreground: #6e4900;
|
||||
--secondary: #d7dcff;
|
||||
--secondary-foreground: #00226e;
|
||||
--muted: #32302e;
|
||||
--muted-foreground: #c3bdb9;
|
||||
--accent: hsl(234.55, 13.58%, 25%);
|
||||
--accent-foreground: #a5b2ff;
|
||||
--destructive: hsl(352.77, 92.56%, 60%);
|
||||
--success: hsl(104.23, 79.59%, 40%);
|
||||
--warning: #ffa45a;
|
||||
--border: hsl(234.55, 13.58%, 30%);
|
||||
--input: hsl(234.55, 13.58%, 35%);
|
||||
--ring: hsl(234.55, 13.58%, 30%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50 antialiased;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
5
tools/citrineos-core-main/apps/operator-ui/src/app/globals.d.ts
vendored
Normal file
5
tools/citrineos-core-main/apps/operator-ui/src/app/globals.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
declare module '*.css';
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Providers } from '@lib/providers';
|
||||
import config from '@lib/utils/config';
|
||||
import { type Metadata } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages } from 'next-intl/server';
|
||||
import localFont from 'next/font/local';
|
||||
import { cookies } from 'next/headers';
|
||||
import React from 'react';
|
||||
import './globals.css';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
|
||||
const roobertFont = localFont({
|
||||
src: [
|
||||
{
|
||||
path: './_fonts/Roobert-Light.woff2',
|
||||
weight: '300',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './_fonts/Roobert-Regular.woff2',
|
||||
weight: '400',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './_fonts/Roobert-Medium.woff2',
|
||||
weight: '500',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './_fonts/Roobert-SemiBold.woff2',
|
||||
weight: '600',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './_fonts/Roobert-Bold.woff2',
|
||||
weight: '700',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './_fonts/Roobert-Heavy.woff2',
|
||||
weight: '800',
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
variable: '--font-roobert',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: config.appName,
|
||||
icons: {
|
||||
icon: '/Citrine_Favicon_256_clear3.png',
|
||||
},
|
||||
};
|
||||
|
||||
const fallbackLocale = 'en';
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const cookieStore = await cookies();
|
||||
const theme = cookieStore.get('theme');
|
||||
const mode = theme?.value === 'dark' ? 'dark' : 'light';
|
||||
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
const fallbackMessages = await getMessages({ locale: fallbackLocale });
|
||||
|
||||
return (
|
||||
<html lang={locale} className={roobertFont.variable} suppressHydrationWarning>
|
||||
<body>
|
||||
<NextIntlClientProvider locale={locale} messages={{ ...fallbackMessages, ...messages }}>
|
||||
<NuqsAdapter>
|
||||
<Providers defaultMode={mode}>{children}</Providers>
|
||||
</NuqsAdapter>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { authProvider } from '@lib/providers/auth-provider';
|
||||
|
||||
const LoginPage = authProvider.getLoginPage();
|
||||
|
||||
export default function Login() {
|
||||
return <LoginPage />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { ErrorComponent } from '@lib/client/components/ui/error-component';
|
||||
import { Authenticated } from '@refinedev/core';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<Suspense>
|
||||
<Authenticated key="not-found">
|
||||
<ErrorComponent />
|
||||
</Authenticated>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
10
tools/citrineos-core-main/apps/operator-ui/src/app/page.tsx
Normal file
10
tools/citrineos-core-main/apps/operator-ui/src/app/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function RootPage() {
|
||||
redirect('/overview');
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader } from '@lib/client/components/ui/card';
|
||||
import { heading2Style } from '@lib/client/styles/page';
|
||||
import { useTranslate } from '@refinedev/core';
|
||||
|
||||
export const AccessDeniedFallbackCard = () => {
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className={heading2Style}>{translate('accessDenied')}</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>{translate('buttons.notAccessTitle')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MainMenu, MenuSection } from '@lib/client/components/main-menu/main.menu';
|
||||
import { ConnectionModal } from '@lib/client/components/modals/shared/connection-modal/connection.modal';
|
||||
import AppModal from '@lib/client/components/modals';
|
||||
import { useIsAuthenticated, useTranslate, useGetIdentity } from '@refinedev/core';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import type { KeycloakUserIdentity } from '@lib/providers/auth-provider/keycloak-auth-provider';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { heading2Style } from '@lib/client/styles/page';
|
||||
import { HeaderBanner } from '@lib/client/components/ui/header-banner';
|
||||
|
||||
type AuthenticatedLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
authKey: string;
|
||||
fallback?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function AuthenticatedLayout({
|
||||
children,
|
||||
authKey,
|
||||
fallback,
|
||||
}: AuthenticatedLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const translate = useTranslate();
|
||||
const [showFirstLoginModal, setShowFirstLoginModal] = useState(false);
|
||||
|
||||
const { data, isLoading } = useIsAuthenticated();
|
||||
const { data: identity } = useGetIdentity<KeycloakUserIdentity>();
|
||||
|
||||
// First login detection logic
|
||||
useEffect(() => {
|
||||
if (data?.authenticated && identity?.id) {
|
||||
const firstLoginKey = `firstLoginHelp:${identity.id}`;
|
||||
const hasSeenFirstLoginModal = localStorage.getItem(firstLoginKey);
|
||||
|
||||
if (!hasSeenFirstLoginModal) {
|
||||
setShowFirstLoginModal(true);
|
||||
localStorage.setItem(firstLoginKey, 'true');
|
||||
}
|
||||
}
|
||||
}, [data, identity]);
|
||||
|
||||
const handleFirstLoginModalClose = () => {
|
||||
setShowFirstLoginModal(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && data?.authenticated === false) {
|
||||
console.log('Redirecting to login...');
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isLoading, data, router]);
|
||||
|
||||
// Determine active section from pathname
|
||||
const activeSection = pathname?.split('/')[1] || 'overview';
|
||||
|
||||
// Determine route class name
|
||||
const routeClassName = pathname?.replace(/\//g, '-').substring(1) || 'root';
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
fallback || (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-center">
|
||||
<h2 className={heading2Style}>{translate('pages.checkingAuth')}</h2>
|
||||
<Loader2 className="size-8 animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading while redirecting
|
||||
if (!data?.authenticated) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-center">
|
||||
<h2 className={heading2Style}>{translate('pages.redirectingToLogin')}</h2>
|
||||
<Loader2 className="size-8 animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="min-h-screen ml-20 bg-cover bg-[url(/gradient.svg)] dark:bg-[url(/gradient-dark.svg)]">
|
||||
<MainMenu activeSection={activeSection as MenuSection} />
|
||||
<div className="flex flex-col">
|
||||
<AppModal />
|
||||
<main className={`content-container ${routeClassName}`}>
|
||||
<div className="content-outer-wrap">
|
||||
<div className="content-inner-wrap">
|
||||
<>
|
||||
<HeaderBanner />
|
||||
{children}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectionModal
|
||||
open={showFirstLoginModal}
|
||||
onClose={handleFirstLoginModalClose}
|
||||
isFirstLogin={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { Button, type ButtonProps } from '@lib/client/components/ui/button';
|
||||
import { CanAccess, useSaveButton, useTranslate } from '@refinedev/core';
|
||||
import type {
|
||||
RefineButtonResourceProps,
|
||||
RefineButtonSingleProps,
|
||||
RefineSaveButtonProps,
|
||||
} from '@refinedev/ui-types';
|
||||
import { Check, Save, Send, Trash } from 'lucide-react';
|
||||
import type { ComponentProps, FC } from 'react';
|
||||
import { buttonIconSize } from '@lib/client/styles/icon';
|
||||
|
||||
export enum FormButtonVariants {
|
||||
confirm = 'confirm',
|
||||
delete = 'delete',
|
||||
save = 'save',
|
||||
submit = 'submit',
|
||||
}
|
||||
|
||||
export type FormButtonProps = ButtonProps &
|
||||
RefineSaveButtonProps &
|
||||
RefineButtonResourceProps &
|
||||
RefineButtonSingleProps & {
|
||||
access?: Omit<ComponentProps<typeof CanAccess>, 'children' | 'action' | 'resource' | 'params'>;
|
||||
submitButtonVariant?: FormButtonVariants;
|
||||
submitButtonLabel?: string;
|
||||
};
|
||||
|
||||
export const FormButton: FC<FormButtonProps> = ({
|
||||
hideText = false,
|
||||
children,
|
||||
accessControl,
|
||||
access,
|
||||
resource,
|
||||
recordItemId,
|
||||
...props
|
||||
}) => {
|
||||
const translate = useTranslate();
|
||||
const { label } = useSaveButton();
|
||||
|
||||
if (accessControl?.hideIfUnauthorized && accessControl.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { submitButtonLabel, submitButtonVariant, ...buttonProps } = props;
|
||||
|
||||
switch (submitButtonVariant) {
|
||||
case FormButtonVariants.confirm:
|
||||
return (
|
||||
<Button {...buttonProps} variant="success">
|
||||
<Check className={buttonIconSize} />
|
||||
{!hideText && (submitButtonLabel ?? translate('buttons.confirmText'))}
|
||||
</Button>
|
||||
);
|
||||
case FormButtonVariants.delete:
|
||||
return (
|
||||
<Button {...buttonProps} variant="destructive">
|
||||
<Trash className={buttonIconSize} />
|
||||
{!hideText && (submitButtonLabel ?? translate('buttons.delete'))}
|
||||
</Button>
|
||||
);
|
||||
case FormButtonVariants.submit:
|
||||
return (
|
||||
<Button {...buttonProps} variant="secondary">
|
||||
<Send className={buttonIconSize} />
|
||||
{!hideText && (submitButtonLabel ?? translate('buttons.submit'))}
|
||||
</Button>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Button {...buttonProps}>
|
||||
<Save className={buttonIconSize} />
|
||||
{!hideText && (children ?? label ?? translate('buttons.save'))}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
FormButton.displayName = 'FormButton';
|
||||
@@ -0,0 +1,155 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { Button } from '@lib/client/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@lib/client/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@lib/client/components/ui/popover';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface ComboboxProps<T> {
|
||||
options: Array<{ label: string; value: T }>;
|
||||
value?: T;
|
||||
onSelect?: (value: T, label: string) => void;
|
||||
onSearch?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
isLoading?: boolean;
|
||||
skipValue?: boolean;
|
||||
disabled?: boolean;
|
||||
allowManualEntry?: boolean;
|
||||
}
|
||||
|
||||
export function Combobox<T>({
|
||||
options,
|
||||
value,
|
||||
onSelect,
|
||||
onSearch,
|
||||
placeholder = 'Select an option',
|
||||
searchPlaceholder = 'Search',
|
||||
emptyMessage = 'No results found.',
|
||||
isLoading = false,
|
||||
skipValue = false,
|
||||
disabled = false,
|
||||
allowManualEntry = false,
|
||||
}: ComboboxProps<T>) {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [selectedOption, setSelectedOption] = useState<{ label: string; value: T } | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const matchingOption = options.find((option) => option.value === value);
|
||||
if (matchingOption) {
|
||||
setSelectedOption({ ...matchingOption });
|
||||
} else if (allowManualEntry && typeof value === 'string') {
|
||||
setSelectedOption({ label: value, value });
|
||||
} else {
|
||||
setSelectedOption(undefined);
|
||||
}
|
||||
} else {
|
||||
setSelectedOption(undefined);
|
||||
}
|
||||
}, [value, options, allowManualEntry]);
|
||||
|
||||
const trimmedInput = inputValue.trim();
|
||||
const showManualEntry =
|
||||
allowManualEntry &&
|
||||
trimmedInput !== '' &&
|
||||
!options.some((o) => o.label.toLowerCase() === trimmedInput.toLowerCase());
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) setInputValue('');
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
disabled={isLoading || disabled}
|
||||
>
|
||||
{isLoading ? 'Loading...' : selectedOption?.label || placeholder}
|
||||
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
|
||||
<Command shouldFilter={!onSearch}>
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder ?? 'Search'}
|
||||
onValueChange={(val) => {
|
||||
setInputValue(val);
|
||||
onSearch?.(val);
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{isLoading ? 'Loading...' : emptyMessage}</CommandEmpty>
|
||||
{showManualEntry && (
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={`__manual__${trimmedInput}`}
|
||||
onSelect={() => {
|
||||
const manualOption = {
|
||||
label: trimmedInput,
|
||||
value: trimmedInput as unknown as T,
|
||||
};
|
||||
if (!skipValue) {
|
||||
setSelectedOption(manualOption);
|
||||
}
|
||||
onSelect?.(trimmedInput as unknown as T, trimmedInput);
|
||||
setOpen(false);
|
||||
setInputValue('');
|
||||
}}
|
||||
>
|
||||
Use "{trimmedInput}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandGroup>
|
||||
{options.map((option, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={String(option.value)}
|
||||
onSelect={() => {
|
||||
if (!skipValue) {
|
||||
setSelectedOption(option);
|
||||
}
|
||||
onSelect?.(option.value, option.label);
|
||||
setOpen(false);
|
||||
setInputValue('');
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'mr-2 size-4',
|
||||
selectedOption?.label === option.label ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Button } from '@lib/client/components/ui/button';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { buttonIconSize } from '@lib/client/styles/icon';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
const defaultDebounceInMillis = 300;
|
||||
const maxInputLength = 100;
|
||||
|
||||
export interface DebounceSearchProps {
|
||||
onSearch: any;
|
||||
placeholder: string;
|
||||
debounceInMillis?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DebounceSearch = ({
|
||||
onSearch,
|
||||
placeholder,
|
||||
debounceInMillis,
|
||||
className,
|
||||
}: DebounceSearchProps) => {
|
||||
const [value, setValue] = React.useState('');
|
||||
|
||||
const onChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = event.target.value;
|
||||
setValue(newValue);
|
||||
onSearch(newValue);
|
||||
},
|
||||
[onSearch],
|
||||
);
|
||||
|
||||
const onChangeDebounce = useMemo(
|
||||
() => debounce(onChange, debounceInMillis ?? defaultDebounceInMillis),
|
||||
[debounceInMillis, onChange],
|
||||
);
|
||||
|
||||
const handleClear = () => {
|
||||
setValue('');
|
||||
onSearch('');
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
onSearch(value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
// to cancel debounce when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onChangeDebounce.cancel();
|
||||
};
|
||||
}, [onChangeDebounce]);
|
||||
|
||||
return (
|
||||
<div className={className ?? 'relative w-[300px]'}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
onChangeDebounce(e);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
maxLength={maxInputLength}
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 gap-0.5">
|
||||
{value && (
|
||||
<Button type="button" variant="ghost" size="xs" onClick={handleClear}>
|
||||
<X className={`${buttonIconSize} text-destructive`} />
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="xs" onClick={handleSearch}>
|
||||
<Search className={buttonIconSize} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { Button } from '@lib/client/components/ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { buttonIconSize } from '@lib/client/styles/icon';
|
||||
import React from 'react';
|
||||
import { useTranslate } from '@refinedev/core';
|
||||
|
||||
export const AddArrayItemButton = ({
|
||||
onAppendAction,
|
||||
itemLabel = 'Item',
|
||||
}: {
|
||||
onAppendAction: () => void;
|
||||
itemLabel?: string;
|
||||
}) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
// type="button" necessary to not accidentally trigger any form submits
|
||||
return (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onAppendAction}>
|
||||
<Plus className={buttonIconSize} />
|
||||
{translate('buttons.add')} {itemLabel}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { autocompleteAddress } from '@lib/server/actions/map/autocompleteAddress';
|
||||
import { getPlaceDetails } from '@lib/server/actions/map/getPlaceDetails';
|
||||
|
||||
type Prediction = {
|
||||
description: string;
|
||||
place_id: string;
|
||||
};
|
||||
|
||||
export type PlaceDetails = {
|
||||
address: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
countryCode?: string;
|
||||
countryName?: string;
|
||||
coordinates: { lat: number; lng: number };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChangeAction: (value: string) => void;
|
||||
onSelectPlaceAction: (placeId: string, placeDetails: PlaceDetails) => void;
|
||||
countryCode?: string;
|
||||
placeholder?: string;
|
||||
sessionToken?: string;
|
||||
};
|
||||
|
||||
export const AddressAutocomplete: React.FC<Props> = ({
|
||||
value,
|
||||
onChangeAction,
|
||||
onSelectPlaceAction,
|
||||
countryCode,
|
||||
placeholder = 'Start typing an address...',
|
||||
sessionToken,
|
||||
}) => {
|
||||
const [predictions, setPredictions] = useState<Prediction[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchPredictions = debounce((input: string) => {
|
||||
if (!input) {
|
||||
setPredictions([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
autocompleteAddress(input, countryCode?.toUpperCase(), sessionToken)
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
return setPredictions(result.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
setPredictions([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, 300);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPredictions(value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (prediction: Prediction) => {
|
||||
setShowDropdown(false);
|
||||
onChangeAction(prediction.description);
|
||||
|
||||
getPlaceDetails(prediction.place_id, sessionToken)
|
||||
.then((result) => {
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
const details = result.data;
|
||||
|
||||
const location = details.location;
|
||||
const components = details.addressComponents;
|
||||
|
||||
const getComponent = (type: string) =>
|
||||
components?.find((c) => c.types.includes(type))?.longText;
|
||||
|
||||
const getComponentShort = (type: string) =>
|
||||
components?.find((c) => c.types.includes(type))?.shortText;
|
||||
|
||||
const streetNumber = getComponent('street_number') || '';
|
||||
const route = getComponent('route') || '';
|
||||
const streetAddress = `${streetNumber} ${route}`.trim();
|
||||
|
||||
const adminArea =
|
||||
getComponent('administrative_area_level_1') ||
|
||||
getComponent('administrative_area_level_2') ||
|
||||
'';
|
||||
|
||||
const fullDetails: PlaceDetails = {
|
||||
address: streetAddress || details.formattedAddress,
|
||||
city:
|
||||
getComponent('locality') ||
|
||||
getComponent('sublocality') ||
|
||||
getComponent('administrative_area_level_2') ||
|
||||
'',
|
||||
state: adminArea,
|
||||
postalCode: getComponent('postal_code') || '',
|
||||
countryCode: getComponentShort('country') || '',
|
||||
countryName: getComponent('country') || '',
|
||||
coordinates: {
|
||||
lat: location.latitude,
|
||||
lng: location.longitude,
|
||||
},
|
||||
};
|
||||
|
||||
onChangeAction(fullDetails.address);
|
||||
onSelectPlaceAction(prediction.place_id, fullDetails);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch place details', err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={containerRef}>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChangeAction(e.target.value);
|
||||
setShowDropdown(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (predictions.length > 0) {
|
||||
setShowDropdown(true);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
/>
|
||||
{showDropdown && (predictions.length > 0 || loading) && (
|
||||
<ul className="absolute z-50 w-full mt-1 bg-popover text-popover-foreground border border-border rounded-md shadow-md max-h-60 overflow-auto">
|
||||
{loading && predictions.length === 0 && (
|
||||
<li className="px-3 py-2 text-sm text-muted-foreground">Loading...</li>
|
||||
)}
|
||||
{predictions.map((p) => (
|
||||
<li
|
||||
key={p.place_id}
|
||||
className="px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(p);
|
||||
}}
|
||||
>
|
||||
{p.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Controller,
|
||||
type ControllerFieldState,
|
||||
type ControllerProps,
|
||||
type ControllerRenderProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from 'react-hook-form';
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from '@lib/client/components/ui/field';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@lib/client/components/ui/select';
|
||||
import { Checkbox } from '@lib/client/components/ui/checkbox';
|
||||
import { Combobox, type ComboboxProps } from '@lib/client/components/combobox';
|
||||
import { MultiSelect, type MultiSelectProps } from '@lib/client/components/multi-select';
|
||||
|
||||
type Props<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
control: ControllerProps<TFieldValues, TName>['control'];
|
||||
name: ControllerProps<TFieldValues, TName>['name'];
|
||||
label: string | React.ReactElement;
|
||||
description?: string;
|
||||
className?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type PropsWithChildren<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = Props<TFieldValues, TName> & {
|
||||
children: React.ReactElement;
|
||||
};
|
||||
|
||||
export const formLabelWrapperStyle = 'flex items-center gap-2';
|
||||
export const formLabelStyle = 'text-base font-semibold';
|
||||
export const formRequiredAsterisk = <span className="text-destructive">*</span>;
|
||||
export const formCheckboxStyle = 'w-4!';
|
||||
export const nestedFormRowFlex = 'flex items-center gap-6';
|
||||
|
||||
const FieldWrapper = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(
|
||||
props: PropsWithChildren<TFieldValues, TName> & {
|
||||
field: ControllerRenderProps<TFieldValues, TName>;
|
||||
fieldState: ControllerFieldState;
|
||||
},
|
||||
) => {
|
||||
return (
|
||||
<Field data-invalid={props.fieldState.invalid}>
|
||||
<FieldLabel htmlFor={props.field.name} className={formLabelWrapperStyle}>
|
||||
<span className={formLabelStyle}>{props.label}</span>
|
||||
{props.required && formRequiredAsterisk}
|
||||
</FieldLabel>
|
||||
{props.children}
|
||||
{props.description && <FieldDescription>{props.description}</FieldDescription>}
|
||||
{props.fieldState.invalid && <FieldError errors={[props.fieldState.error]} />}
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A generic FormField property that can be used within the custom <Form> wrapper.
|
||||
* Usable for simple input components such as <Input>, <Textarea>, and <Checkbox>.
|
||||
* For more complicated form components, they will have their own dedicated component below.
|
||||
*
|
||||
* @param props
|
||||
* @constructor
|
||||
*/
|
||||
export const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(
|
||||
props: PropsWithChildren<TFieldValues, TName>,
|
||||
) => {
|
||||
return (
|
||||
<Controller
|
||||
name={props.name}
|
||||
control={props.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldWrapper {...{ ...props, field, fieldState }}>
|
||||
{props.children && React.cloneElement(props.children, field)}
|
||||
</FieldWrapper>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectFormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(
|
||||
props: Props<TFieldValues, TName> & {
|
||||
options: any[];
|
||||
placeholder?: string;
|
||||
},
|
||||
) => {
|
||||
return (
|
||||
<Controller
|
||||
name={props.name}
|
||||
control={props.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldWrapper {...{ ...props, field, fieldState }}>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={props.placeholder ?? 'Select Item'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.options.map((o) => (
|
||||
<SelectItem key={o} value={o}>
|
||||
{o}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComboboxFormField = <
|
||||
T,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(
|
||||
props: Props<TFieldValues, TName> & ComboboxProps<T>,
|
||||
) => {
|
||||
return (
|
||||
<Controller
|
||||
name={props.name}
|
||||
control={props.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldWrapper {...{ ...props, field, fieldState }}>
|
||||
<Combobox<T> onSelect={field.onChange} value={field.value} {...props} />
|
||||
</FieldWrapper>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type PartialMultiSelectProps<T> = Partial<MultiSelectProps<T>> & {
|
||||
options: T[];
|
||||
setSelectedValues?: (values: T[]) => void;
|
||||
};
|
||||
|
||||
export const MultiSelectFormField = <
|
||||
T extends string,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(
|
||||
props: Props<TFieldValues, TName> & PartialMultiSelectProps<T>,
|
||||
) => {
|
||||
return (
|
||||
<Controller
|
||||
name={props.name}
|
||||
control={props.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldWrapper {...{ ...props, field, fieldState }}>
|
||||
<MultiSelect<T>
|
||||
selectedValues={field.value || []}
|
||||
setSelectedValues={field.onChange}
|
||||
{...props}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A dedicated FormField for Radix UI Checkbox components.
|
||||
* Radix Checkbox uses `checked` + `onCheckedChange` instead of the standard
|
||||
* `value` + `onChange` that react-hook-form provides, so a generic cloneElement
|
||||
* spread does not work for booleans. This component wires them correctly.
|
||||
*/
|
||||
export const CheckboxFormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(
|
||||
props: Props<TFieldValues, TName>,
|
||||
) => {
|
||||
return (
|
||||
<Controller
|
||||
name={props.name}
|
||||
control={props.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldWrapper {...{ ...props, field, fieldState }}>
|
||||
<Checkbox
|
||||
id={field.name}
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
className={formCheckboxStyle}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
FormField.displayName = 'FormField';
|
||||
SelectFormField.displayName = 'SelectFormField';
|
||||
ComboboxFormField.displayName = 'ComboboxFormField';
|
||||
MultiSelectFormField.displayName = 'MultiSelectFormField';
|
||||
CheckboxFormField.displayName = 'CheckboxFormField';
|
||||
@@ -0,0 +1,120 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { FormButton, type FormButtonProps } from '@lib/client/components/buttons/form.button';
|
||||
import { Button } from '@lib/client/components/ui/button';
|
||||
import {
|
||||
useBack,
|
||||
useParsed,
|
||||
useTranslation,
|
||||
type BaseRecord,
|
||||
type HttpError,
|
||||
useTranslate,
|
||||
} from '@refinedev/core';
|
||||
import type { UseFormReturnType } from '@refinedev/react-hook-form';
|
||||
import {
|
||||
useId,
|
||||
type DetailedHTMLProps,
|
||||
type FormHTMLAttributes,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
import { type FieldValues, FormProvider, type UseFormReturn } from 'react-hook-form';
|
||||
import { LoadingIcon } from '@lib/client/components/ui/loading';
|
||||
|
||||
type NativeFormProps = Omit<
|
||||
DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>,
|
||||
'onSubmit'
|
||||
>;
|
||||
|
||||
export type FormProps<
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables extends FieldValues = FieldValues,
|
||||
TContext extends object = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
> = PropsWithChildren &
|
||||
UseFormReturnType<TQueryFnData, TError, TVariables, TContext, TData, TResponse, TResponseError> &
|
||||
FormButtonProps & {
|
||||
formProps?: NativeFormProps;
|
||||
loading?: boolean;
|
||||
submitHandler?: (data: any) => void;
|
||||
cancelHandler?: () => void;
|
||||
hideCancel?: boolean;
|
||||
showFormErrors?: boolean; // for debugging form issues
|
||||
};
|
||||
|
||||
export const Form = <
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TContext extends object = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
>({
|
||||
formProps,
|
||||
loading,
|
||||
submitHandler,
|
||||
cancelHandler,
|
||||
showFormErrors,
|
||||
...props
|
||||
}: FormProps<TQueryFnData, TError, TFieldValues, TContext, TData, TResponse, TResponseError>) => {
|
||||
const formId = useId();
|
||||
const translate = useTranslate();
|
||||
const { action } = useParsed();
|
||||
const back = useBack();
|
||||
|
||||
const onBack = action !== 'list' || typeof action !== 'undefined' ? back : undefined;
|
||||
|
||||
const onSubmit = (data: TFieldValues) => {
|
||||
if (submitHandler) {
|
||||
submitHandler(data);
|
||||
} else {
|
||||
props.refineCore.onFinish(data).then();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...(props as unknown as UseFormReturn<TFieldValues, TContext, TFieldValues>)}>
|
||||
<form {...formProps} onSubmit={props.handleSubmit(onSubmit)} id={formId}>
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
{props.children}
|
||||
{showFormErrors && Object.keys(props.formState.errors).length > 0 && (
|
||||
<div className="text-destructive">{JSON.stringify(props.formState.errors)}</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
{loading && <LoadingIcon className="size-6" />}
|
||||
{!props.hideCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (cancelHandler) {
|
||||
cancelHandler();
|
||||
} else if (onBack) {
|
||||
onBack();
|
||||
}
|
||||
}}
|
||||
disabled={props.refineCore.formLoading || loading}
|
||||
variant="outline"
|
||||
>
|
||||
{translate('buttons.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<FormButton
|
||||
submitButtonVariant={props.submitButtonVariant}
|
||||
submitButtonLabel={props.submitButtonLabel}
|
||||
type="submit"
|
||||
loading={props.refineCore.formLoading || loading}
|
||||
disabled={props.refineCore.formLoading || loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { buttonIconSize } from '@lib/client/styles/icon';
|
||||
import { Button } from '@lib/client/components/ui/button';
|
||||
|
||||
export const RemoveArrayItemButton = ({ onRemoveAction }: { onRemoveAction: () => void }) => {
|
||||
// type="button" necessary to not accidentally trigger any form submits
|
||||
return (
|
||||
<Button type="button" variant="destructive" size="xs" onClick={onRemoveAction}>
|
||||
<X className={buttonIconSize} />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { NOT_APPLICABLE } from '@lib/utils/consts';
|
||||
|
||||
interface KeyValueDisplayProps {
|
||||
keyLabel: string | React.ReactNode;
|
||||
value: any;
|
||||
valueRender?: (value?: any) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const KeyValueDisplay = ({ keyLabel, value, valueRender }: KeyValueDisplayProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-accent-foreground font-semibold">{keyLabel}</span>
|
||||
{valueRender ? valueRender(value) : <span>{value ?? NOT_APPLICABLE}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@lib/client/components/ui/button';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { authProvider } from '@lib/providers/auth-provider';
|
||||
import { sidebarIconSize } from '@lib/client/styles/icon';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslate } from '@refinedev/core';
|
||||
|
||||
export const LogoutButton = ({ expanded }: { expanded: boolean }) => {
|
||||
const router = useRouter();
|
||||
const translate = useTranslate();
|
||||
|
||||
const logout = () => {
|
||||
authProvider.logout({}).then((authResponse) => {
|
||||
toast.success(translate('loggedOut'));
|
||||
router.push(authResponse.redirectTo ?? '');
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Button size={expanded ? 'default' : 'icon'} variant="ghost" onClick={logout}>
|
||||
<LogOut className={sidebarIconSize} />
|
||||
{expanded && <span>{translate('buttons.logout')}</span>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { Logo } from '@lib/client/components/title';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clipboard,
|
||||
EvCharger,
|
||||
HelpCircle,
|
||||
Home,
|
||||
MapPin,
|
||||
Receipt,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@lib/client/components/ui/button';
|
||||
import { sidebarIconSize } from '@lib/client/styles/icon';
|
||||
import { ThemeToggle } from '@lib/client/components/theme-toggle';
|
||||
import { ConnectionModal } from '@lib/client/components/modals/shared/connection-modal/connection.modal';
|
||||
import { LogoutButton } from '@lib/client/components/logout-button';
|
||||
import { useTranslate } from '@refinedev/core';
|
||||
|
||||
export enum MenuSection {
|
||||
OVERVIEW = 'overview',
|
||||
LOCATIONS = 'locations',
|
||||
CHARGING_STATIONS = 'charging-stations',
|
||||
AUTHORIZATIONS = 'authorizations',
|
||||
TRANSACTIONS = 'transactions',
|
||||
TARIFFS = 'tariffs',
|
||||
PARTNERS = 'partners',
|
||||
}
|
||||
|
||||
export interface MainMenuProps {
|
||||
activeSection: MenuSection;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MainMenu = ({ activeSection }: MainMenuProps) => {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [isHelpOpen, setIsHelpOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLElement>(null);
|
||||
const translate = useTranslate();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setCollapsed(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const mainMenuItems: MenuItem[] = [
|
||||
{
|
||||
key: `/${MenuSection.OVERVIEW}`,
|
||||
label: translate('menu.overview'),
|
||||
icon: <Home className={sidebarIconSize} />,
|
||||
},
|
||||
{
|
||||
key: `/${MenuSection.LOCATIONS}`,
|
||||
label: translate('Locations.Locations'),
|
||||
icon: <MapPin className={sidebarIconSize} />,
|
||||
},
|
||||
{
|
||||
key: `/${MenuSection.CHARGING_STATIONS}`,
|
||||
label: translate('ChargingStations.ChargingStations'),
|
||||
icon: <EvCharger className={sidebarIconSize} />,
|
||||
},
|
||||
{
|
||||
key: `/${MenuSection.AUTHORIZATIONS}`,
|
||||
label: translate('Authorizations.Authorizations'),
|
||||
icon: <Clipboard className={sidebarIconSize} />,
|
||||
},
|
||||
{
|
||||
key: `/${MenuSection.TRANSACTIONS}`,
|
||||
label: translate('Transactions.Transactions'),
|
||||
icon: <ArrowLeftRight className={sidebarIconSize} />,
|
||||
},
|
||||
{
|
||||
key: `/${MenuSection.TARIFFS}`,
|
||||
label: translate('Tariffs.Tariffs'),
|
||||
icon: <Receipt className={sidebarIconSize} />,
|
||||
},
|
||||
{
|
||||
key: `/${MenuSection.PARTNERS}`,
|
||||
label: translate('TenantPartners.TenantPartners'),
|
||||
icon: <Users className={sidebarIconSize} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed left-0 top-0 h-screen bg-card transition-all duration-300 z-40 flex flex-col shadow-md',
|
||||
collapsed ? 'w-20' : 'w-[272px]',
|
||||
)}
|
||||
ref={menuRef}
|
||||
>
|
||||
{/* Logo Section */}
|
||||
<div className="min-h-[130px] flex items-center justify-center px-4">
|
||||
<Logo collapsed={collapsed} />
|
||||
</div>
|
||||
|
||||
{/* Main Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
<ul className="space-y-1 px-3">
|
||||
{mainMenuItems.map((item) => {
|
||||
const isActive = `/${activeSection}` === item.key;
|
||||
return (
|
||||
<li key={item.key}>
|
||||
<Link
|
||||
href={item.key}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-3 rounded-md transition-colors text-sm',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
collapsed && 'justify-center px-2',
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<span className="shrink-0">{item.icon}</span>
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Bottom Menu - Help Link */}
|
||||
<div className="border-t border-border p-3 flex flex-col gap-2 items-center">
|
||||
<ThemeToggle expanded={!collapsed} />
|
||||
<Button variant="ghost" onClick={() => setIsHelpOpen(true)} title="Help">
|
||||
<HelpCircle className={sidebarIconSize} />
|
||||
{!collapsed && <span>{translate('menu.help')}</span>}
|
||||
</Button>
|
||||
<LogoutButton expanded={!collapsed} />
|
||||
</div>
|
||||
|
||||
{/* Collapse Toggle */}
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="absolute top-0 right-0 transform translate-x-1/2 translate-y-[110px] size-8 bg-card text-accent-foreground border-transparent rounded-full shadow-md"
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className={sidebarIconSize} />
|
||||
) : (
|
||||
<ChevronLeft className={sidebarIconSize} />
|
||||
)}
|
||||
</Button>
|
||||
</aside>
|
||||
<ConnectionModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import type { LocationDto } from '@citrineos/base';
|
||||
import type { Marker } from '@googlemaps/markerclusterer';
|
||||
import { AdvancedMarker } from '@vis.gl/react-google-maps';
|
||||
import { MarkerIconCircle } from '@lib/client/components/map/marker.icons';
|
||||
|
||||
export const MapMarkerV2 = ({
|
||||
location,
|
||||
onClickAction,
|
||||
setMarkerRefAction,
|
||||
}: {
|
||||
location: LocationDto;
|
||||
onClickAction: (location: LocationDto) => void;
|
||||
setMarkerRefAction: (marker: Marker | null, id: number) => void;
|
||||
}) => {
|
||||
const handleClick = useCallback(() => onClickAction(location), [onClickAction, location]);
|
||||
const ref = useCallback(
|
||||
(marker: google.maps.marker.AdvancedMarkerElement) =>
|
||||
setMarkerRefAction(marker, location.id ?? 0),
|
||||
[setMarkerRefAction, location.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdvancedMarker
|
||||
position={{
|
||||
lat: location.coordinates.coordinates[1]!,
|
||||
lng: location.coordinates.coordinates[0]!,
|
||||
}}
|
||||
ref={ref}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<MarkerIconCircle
|
||||
fillColor="var(--primary)"
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
}}
|
||||
/>
|
||||
</AdvancedMarker>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import type { LocationDto } from '@citrineos/base';
|
||||
import { InfoWindow, useMap } from '@vis.gl/react-google-maps';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { type Marker, MarkerClusterer } from '@googlemaps/markerclusterer';
|
||||
import { MapMarkerV2 } from '@lib/client/components/map/map.clusters.marker';
|
||||
import { ChargingStationStatusTag } from '@lib/client/pages/charging-stations/charging.station.status.tag';
|
||||
import { MenuSection } from '@lib/client/components/main-menu/main.menu';
|
||||
|
||||
/**
|
||||
* Reference: https://github.com/visgl/react-google-maps/blob/main/examples/marker-clustering/src/clustered-tree-markers.tsx
|
||||
*/
|
||||
export const ClusteredLocationMarkers = ({ locations }: { locations: LocationDto[] }) => {
|
||||
const [markers, setMarkers] = useState<{ [id: number]: Marker }>({});
|
||||
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
|
||||
|
||||
const selectedLocation = useMemo(
|
||||
() =>
|
||||
locations && selectedLocationId ? locations.find((t) => t.id === selectedLocationId)! : null,
|
||||
[locations, selectedLocationId],
|
||||
);
|
||||
|
||||
const map = useMap();
|
||||
const clusterer = useMemo(() => {
|
||||
if (!map) return null;
|
||||
|
||||
return new MarkerClusterer({ map });
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!clusterer) return;
|
||||
|
||||
clusterer.clearMarkers();
|
||||
clusterer.addMarkers(Object.values(markers));
|
||||
}, [clusterer, markers]);
|
||||
|
||||
const setMarkerRef = useCallback((marker: Marker | null, id: number) => {
|
||||
setMarkers((markers) => {
|
||||
if ((marker && markers[id]) || (!marker && !markers[id])) return markers;
|
||||
|
||||
if (marker) {
|
||||
return { ...markers, [id]: marker };
|
||||
} else {
|
||||
const { [id]: _, ...newMarkers } = markers;
|
||||
|
||||
return newMarkers;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleInfoWindowClose = useCallback(() => {
|
||||
setSelectedLocationId(null);
|
||||
}, []);
|
||||
|
||||
const handleMarkerClick = useCallback((location: LocationDto) => {
|
||||
setSelectedLocationId(location.id!);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{locations.map((location) => (
|
||||
<MapMarkerV2
|
||||
key={location.id}
|
||||
location={location}
|
||||
onClickAction={handleMarkerClick}
|
||||
setMarkerRefAction={setMarkerRef}
|
||||
/>
|
||||
))}
|
||||
|
||||
{selectedLocationId && (
|
||||
<InfoWindow
|
||||
headerContent={
|
||||
<span
|
||||
className={`cursor-pointer font-semibold underline text-black hover:text-gray-500 text-lg`}
|
||||
onClick={() =>
|
||||
window.open(`/${MenuSection.LOCATIONS}/${selectedLocationId}`, '_blank')
|
||||
}
|
||||
>
|
||||
{selectedLocation?.name}
|
||||
</span>
|
||||
}
|
||||
className="min-w-30 max-h-50"
|
||||
anchor={markers[selectedLocationId]}
|
||||
onCloseClick={handleInfoWindowClose}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{selectedLocation?.chargingPool && selectedLocation?.chargingPool.length > 0 ? (
|
||||
selectedLocation?.chargingPool.map((charger) => (
|
||||
<div key={charger.id} className="border rounded-sm p-2 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`cursor-pointer font-semibold underline text-base text-black hover:text-gray-500`}
|
||||
onClick={() =>
|
||||
window.open(`/${MenuSection.CHARGING_STATIONS}/${charger.id}`, '_blank')
|
||||
}
|
||||
>
|
||||
{charger.ocppConnectionName}
|
||||
</span>
|
||||
<span
|
||||
className={`${charger.isOnline ? 'text-success' : 'text-destructive'} text-xs`}
|
||||
>
|
||||
{charger.isOnline ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
{charger.evses && charger.evses.length > 0 && (
|
||||
<ChargingStationStatusTag station={charger} />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-black">No chargers.</div>
|
||||
)}
|
||||
</div>
|
||||
</InfoWindow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import config from '@lib/utils/config';
|
||||
import { GeoPoint } from '@lib/utils/GeoPoint';
|
||||
import type { MapMouseEvent } from '@vis.gl/react-google-maps';
|
||||
import { AdvancedMarker, APIProvider, Map } from '@vis.gl/react-google-maps';
|
||||
import type { LocationPickerMapProps } from '@lib/client/components/map/types';
|
||||
import { MarkerIconCircle } from '@lib/client/components/map/marker.icons';
|
||||
import { getGoogleMapsApiKey, setGoogleMapsApiKey } from '@lib/utils/store/maps.slice';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { getGoogleMapsApiKeyAction } from '@lib/server/actions/map/getGoogleMapsApiKeyAction';
|
||||
import { Skeleton } from '@lib/client/components/ui/skeleton';
|
||||
|
||||
export const defaultLatitude = 36.7783;
|
||||
export const defaultLongitude = -119.4179;
|
||||
const defaultZoom = 15;
|
||||
|
||||
/**
|
||||
* MapLocationPicker component that allows selecting a location on the map
|
||||
*/
|
||||
export const MapLocationPicker: React.FC<LocationPickerMapProps> = ({
|
||||
point,
|
||||
zoom = defaultZoom,
|
||||
onLocationSelect,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const apiKey = useSelector(getGoogleMapsApiKey);
|
||||
|
||||
const [position, setPosition] = useState<{ lat: number; lng: number } | undefined>(
|
||||
point
|
||||
? {
|
||||
lat: point.latitude,
|
||||
lng: point.longitude,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKey === undefined) {
|
||||
getGoogleMapsApiKeyAction().then((result) =>
|
||||
dispatch(setGoogleMapsApiKey(result.success ? result.data : '')),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (point) {
|
||||
setPosition({
|
||||
lat: point.latitude,
|
||||
lng: point.longitude,
|
||||
});
|
||||
} else {
|
||||
setPosition(undefined);
|
||||
}
|
||||
}, [point]);
|
||||
|
||||
const handleMapClick = (e: MapMouseEvent) => {
|
||||
if (e.detail.latLng) {
|
||||
const lat = e.detail.latLng.lat;
|
||||
const lng = e.detail.latLng.lng;
|
||||
onLocationSelect(new GeoPoint(lat, lng));
|
||||
}
|
||||
};
|
||||
|
||||
return apiKey === undefined ? (
|
||||
<Skeleton className="size=full" />
|
||||
) : (
|
||||
<div className="size-full">
|
||||
<APIProvider apiKey={apiKey ?? ''}>
|
||||
<Map
|
||||
mapId={config.googleMapsLocationPickerMapId}
|
||||
center={point ? { lat: point.latitude, lng: point.longitude } : undefined}
|
||||
defaultZoom={zoom}
|
||||
onClick={handleMapClick}
|
||||
gestureHandling="cooperative"
|
||||
disableDefaultUI={false}
|
||||
zoomControl={true}
|
||||
fullscreenControl={false}
|
||||
>
|
||||
{point && (
|
||||
<AdvancedMarker position={position}>
|
||||
<MarkerIconCircle
|
||||
fillColor="var(--primary)"
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
}}
|
||||
/>
|
||||
</AdvancedMarker>
|
||||
)}
|
||||
</Map>
|
||||
</APIProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { AdvancedMarker, useAdvancedMarkerRef } from '@vis.gl/react-google-maps';
|
||||
import { ChargingStationIcon, LocationIcon } from '@lib/client/components/map/marker.icons';
|
||||
import type { BaseMapMarkerProps } from '@lib/client/components/map/types';
|
||||
|
||||
export const MapMarkerComponent: React.FC<
|
||||
BaseMapMarkerProps & { type: 'station' | 'location' | 'mixed' }
|
||||
> = ({
|
||||
position,
|
||||
identifier,
|
||||
reactContent,
|
||||
onClick,
|
||||
isSelected,
|
||||
color = 'var(--secondary-color-2)',
|
||||
type,
|
||||
status,
|
||||
}) => {
|
||||
const [markerRef, marker] = useAdvancedMarkerRef();
|
||||
|
||||
// Create the appropriate icon based on type
|
||||
const renderIcon = () => {
|
||||
if (type === 'station') {
|
||||
return <ChargingStationIcon color={color} status={status} />;
|
||||
} else {
|
||||
return <LocationIcon color={color} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Optional custom content can be provided
|
||||
const content = reactContent || renderIcon();
|
||||
|
||||
// Handle click event
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick(identifier, type);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdvancedMarker
|
||||
ref={markerRef}
|
||||
position={position}
|
||||
onClick={handleClick}
|
||||
className={isSelected ? 'selected-marker' : ''}
|
||||
>
|
||||
{content}
|
||||
</AdvancedMarker>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,572 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import type { LocationDto } from '@citrineos/base';
|
||||
import { MapMarkerComponent } from '@lib/client/components/map/map.marker';
|
||||
import { ClusterIcon } from '@lib/client/components/map/marker.icons';
|
||||
import type {
|
||||
ClusterInfo,
|
||||
LocationGroup,
|
||||
MapMarkerData,
|
||||
MapProps,
|
||||
} from '@lib/client/components/map/types';
|
||||
import { ActionType, ResourceType } from '@lib/utils/access.types';
|
||||
import config from '@lib/utils/config';
|
||||
import { CanAccess } from '@refinedev/core';
|
||||
import {
|
||||
AdvancedMarker,
|
||||
APILoadingStatus,
|
||||
APIProvider,
|
||||
Map as GoogleMap,
|
||||
useApiLoadingStatus,
|
||||
useMap,
|
||||
} from '@vis.gl/react-google-maps';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { getGoogleMapsApiKey, setGoogleMapsApiKey } from '@lib/utils/store/maps.slice';
|
||||
import { getGoogleMapsApiKeyAction } from '@lib/server/actions/map/getGoogleMapsApiKeyAction';
|
||||
import { Skeleton } from '@lib/client/components/ui/skeleton';
|
||||
|
||||
// https://visgl.github.io/react-google-maps/docs/api-reference/components/map#camera-control
|
||||
const zoomMax = 5;
|
||||
|
||||
/**
|
||||
* Main map component that supports marker clustering
|
||||
*/
|
||||
export const LocationMap: React.FC<MapProps> = ({
|
||||
locations = [],
|
||||
defaultCenter = { lat: 36.7783, lng: -119.4179 },
|
||||
zoom = 10,
|
||||
onMarkerClick,
|
||||
selectedMarkerId,
|
||||
clusterByLocation = true,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const apiKey = useSelector(getGoogleMapsApiKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKey === undefined) {
|
||||
getGoogleMapsApiKeyAction().then((result) =>
|
||||
dispatch(setGoogleMapsApiKey(result.success ? result.data : '')),
|
||||
);
|
||||
}
|
||||
}, [apiKey, dispatch]);
|
||||
|
||||
// Create station markers from location data
|
||||
const stationMarkers: MapMarkerData[] = useMemo(() => {
|
||||
return locations
|
||||
.filter((location) => location.coordinates)
|
||||
.flatMap((location) => {
|
||||
return (location.chargingPool || []).map((station) => {
|
||||
const coordinates = station.coordinates || location.coordinates;
|
||||
const position = {
|
||||
lat: coordinates?.coordinates[1] || 0,
|
||||
lng: coordinates?.coordinates[0] || 0,
|
||||
};
|
||||
|
||||
return {
|
||||
position,
|
||||
identifier: station.ocppConnectionName,
|
||||
type: 'station' as const,
|
||||
locationId: location.id!.toString(),
|
||||
status: station.isOnline ? 'online' : ('offline' as const),
|
||||
color: station.isOnline ? 'var(--primary-color-1)' : 'var(--secondary-color-2)',
|
||||
} as MapMarkerData;
|
||||
});
|
||||
});
|
||||
}, [locations]);
|
||||
|
||||
// Create location markers
|
||||
const locationMarkers: MapMarkerData[] = useMemo(() => {
|
||||
return locations
|
||||
.filter((location) => location.coordinates)
|
||||
.map((location) => {
|
||||
const position = {
|
||||
lat: location.coordinates.coordinates[1],
|
||||
lng: location.coordinates.coordinates[0],
|
||||
};
|
||||
|
||||
const status = determineLocationStatus(location);
|
||||
|
||||
return {
|
||||
position,
|
||||
identifier: location.id!.toString(),
|
||||
type: 'location' as const,
|
||||
status,
|
||||
color: determineLocationColor(status),
|
||||
} as MapMarkerData;
|
||||
});
|
||||
}, [locations]);
|
||||
|
||||
// Add a fallback marker if there are no markers
|
||||
const allMarkers: MapMarkerData[] = useMemo(() => {
|
||||
if (stationMarkers.length === 0 && locationMarkers.length === 0) {
|
||||
return [
|
||||
{
|
||||
position: defaultCenter,
|
||||
identifier: 'default',
|
||||
type: 'location' as const,
|
||||
status: 'offline' as const,
|
||||
color: 'var(--secondary-color-2)',
|
||||
} as MapMarkerData,
|
||||
];
|
||||
}
|
||||
return [...stationMarkers, ...locationMarkers];
|
||||
}, [stationMarkers, locationMarkers, defaultCenter]);
|
||||
|
||||
return apiKey === undefined ? (
|
||||
<Skeleton className="size-full" />
|
||||
) : (
|
||||
<CanAccess resource={ResourceType.LOCATIONS} action={ActionType.LIST}>
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<MapWithClustering
|
||||
locations={locations}
|
||||
markers={allMarkers}
|
||||
defaultCenter={defaultCenter}
|
||||
zoom={zoom}
|
||||
onMarkerClick={onMarkerClick}
|
||||
selectedMarkerId={selectedMarkerId}
|
||||
clusterByLocation={clusterByLocation}
|
||||
/>
|
||||
</APIProvider>
|
||||
</CanAccess>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper component that handles clustering logic
|
||||
const MapWithClustering: React.FC<{
|
||||
markers: MapMarkerData[];
|
||||
locations: MapProps['locations'];
|
||||
defaultCenter: MapProps['defaultCenter'];
|
||||
zoom: MapProps['zoom'];
|
||||
onMarkerClick: MapProps['onMarkerClick'];
|
||||
selectedMarkerId: MapProps['selectedMarkerId'];
|
||||
clusterByLocation: MapProps['clusterByLocation'];
|
||||
}> = ({
|
||||
markers,
|
||||
locations = [],
|
||||
defaultCenter,
|
||||
zoom: initialZoom = zoomMax,
|
||||
onMarkerClick,
|
||||
selectedMarkerId,
|
||||
clusterByLocation = true,
|
||||
}) => {
|
||||
// Track if map is fully initialized and ready for markers
|
||||
const [mapFullyInitialized, setMapFullyInitialized] = useState(false);
|
||||
const status = useApiLoadingStatus();
|
||||
const [visibleElements, setVisibleElements] = useState<(ClusterInfo | MapMarkerData)[]>([]);
|
||||
const [zoom, setZoom] = useState(initialZoom);
|
||||
const [bounds, setBounds] = useState<google.maps.LatLngBounds | null>(null);
|
||||
const map = useMap();
|
||||
|
||||
// Wait until the map API is fully loaded
|
||||
useEffect(() => {
|
||||
if (status === APILoadingStatus.LOADED) {
|
||||
setMapFullyInitialized(true);
|
||||
} else {
|
||||
setMapFullyInitialized(false);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
// Update visible elements when map bounds change or markers change
|
||||
useEffect(() => {
|
||||
if (!map || !bounds || !markers) return;
|
||||
|
||||
// Filter markers to those in the current view
|
||||
const visibleMarkers = markers.filter((marker) => bounds.contains(marker.position));
|
||||
|
||||
// Set up clustering based on zoom level and location grouping preference
|
||||
if (zoom <= zoomMax && clusterByLocation) {
|
||||
// High-level clustering - create clusters of locations
|
||||
const clusters = createLocationClusters(visibleMarkers, locations, bounds);
|
||||
setVisibleElements(clusters);
|
||||
// } else if (zoom <= 14 && clusterByLocation) {
|
||||
// // Mid-level clustering - show individual locations and cluster stations
|
||||
// const elements = createLocationBasedElements(visibleMarkers, locations);
|
||||
// setVisibleElements(elements);
|
||||
} else {
|
||||
// Low-level - show individual stations
|
||||
setVisibleElements(visibleMarkers);
|
||||
}
|
||||
}, [map, bounds, zoom, markers, locations, clusterByLocation]);
|
||||
|
||||
// Set up event listeners for map changes
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const updateZoom = () => {
|
||||
const newZoom = map.getZoom();
|
||||
if (newZoom) setZoom(newZoom);
|
||||
};
|
||||
updateZoom();
|
||||
const zoomListener = map.addListener('zoom_changed', updateZoom);
|
||||
|
||||
const updateBounds = () => {
|
||||
const newBounds = map.getBounds();
|
||||
if (newBounds) setBounds(newBounds);
|
||||
};
|
||||
updateBounds();
|
||||
const boundsListener = map.addListener('bounds_changed', updateBounds);
|
||||
|
||||
return () => {
|
||||
google.maps.event.removeListener(zoomListener);
|
||||
google.maps.event.removeListener(boundsListener);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
// Set map bounds to include all markers when map is initialized or markers change
|
||||
useEffect(() => {
|
||||
if (map && markers.length > 0) {
|
||||
const newBounds = new google.maps.LatLngBounds();
|
||||
|
||||
markers.forEach((marker) => {
|
||||
newBounds.extend(marker.position);
|
||||
});
|
||||
|
||||
map.fitBounds(newBounds);
|
||||
}
|
||||
}, [map, markers]);
|
||||
|
||||
// Ensure selected marker is in view
|
||||
useEffect(() => {
|
||||
if (map && selectedMarkerId) {
|
||||
const selectedMarker = markers.find((marker) => marker.identifier === selectedMarkerId);
|
||||
if (selectedMarker) {
|
||||
map.panTo(selectedMarker.position);
|
||||
|
||||
// Zoom in a bit if we're zoomed out too far
|
||||
if (zoom < zoomMax) {
|
||||
map.setZoom(zoomMax);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedMarkerId, map, markers, zoom]);
|
||||
|
||||
// Render the map and markers/clusters
|
||||
return (
|
||||
<GoogleMap
|
||||
mapId={config.googleMapsOverviewMapId}
|
||||
defaultCenter={defaultCenter}
|
||||
defaultZoom={initialZoom}
|
||||
gestureHandling="cooperative"
|
||||
disableDefaultUI={false}
|
||||
zoomControl={true}
|
||||
fullscreenControl={false}
|
||||
>
|
||||
{mapFullyInitialized &&
|
||||
visibleElements.map((element, index) => {
|
||||
// Handle cluster elements
|
||||
if ('count' in element) {
|
||||
return (
|
||||
<AdvancedMarker
|
||||
key={`cluster-${index}`}
|
||||
position={element.position}
|
||||
onClick={() => {
|
||||
// Zoom in when cluster is clicked
|
||||
if (map) {
|
||||
const bounds = new google.maps.LatLngBounds();
|
||||
element.markers.forEach((marker) => {
|
||||
bounds.extend(marker.position);
|
||||
});
|
||||
map.fitBounds(bounds);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ClusterIcon
|
||||
count={element.count}
|
||||
type={element.type}
|
||||
color={'var(--grayscale-color-1)'}
|
||||
/>
|
||||
</AdvancedMarker>
|
||||
);
|
||||
}
|
||||
// if ('markers' in element && element.markers) {
|
||||
// }
|
||||
// Handle regular marker elements
|
||||
return (
|
||||
<MapMarkerComponent
|
||||
key={element.identifier}
|
||||
position={element.position}
|
||||
identifier={element.identifier}
|
||||
reactContent={element.reactContent}
|
||||
onClick={
|
||||
onMarkerClick ? () => onMarkerClick(element.identifier, element.type) : undefined
|
||||
}
|
||||
isSelected={element.identifier === selectedMarkerId}
|
||||
color={element.color || 'var(--secondary-color-2)'}
|
||||
type={element.type}
|
||||
status={element.status}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</GoogleMap>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to create location-based clusters
|
||||
function createLocationClusters(
|
||||
visibleMarkers: MapMarkerData[],
|
||||
locations: MapProps['locations'] = [],
|
||||
bounds: google.maps.LatLngBounds,
|
||||
): (ClusterInfo | MapMarkerData)[] {
|
||||
// First, create location groups
|
||||
const locationGroups = createLocationGroups(visibleMarkers, locations);
|
||||
|
||||
// No location groups, just return the markers
|
||||
if (locationGroups.length === 0) {
|
||||
return visibleMarkers;
|
||||
}
|
||||
|
||||
// Group locations that are close to each other
|
||||
const clusters: ClusterInfo[] = [];
|
||||
const processedLocations = new Set<string>();
|
||||
const distanceThreshold = calculateDistanceThreshold(bounds);
|
||||
|
||||
for (let i = 0; i < locationGroups.length; i++) {
|
||||
const group = locationGroups[i];
|
||||
|
||||
// Skip if already in a cluster
|
||||
if (processedLocations.has(group.locationId)) continue;
|
||||
|
||||
// Start a new potential cluster
|
||||
const clusterMarkers: MapMarkerData[] = [];
|
||||
const locationIds = new Set<string>();
|
||||
|
||||
// Add this location to the cluster
|
||||
clusterMarkers.push(group.locationMarker);
|
||||
clusterMarkers.push(...group.stationMarkers);
|
||||
locationIds.add(group.locationId);
|
||||
processedLocations.add(group.locationId);
|
||||
|
||||
// Look for nearby locations to add to the cluster
|
||||
for (let j = 0; j < locationGroups.length; j++) {
|
||||
if (i === j) continue;
|
||||
const otherGroup = locationGroups[j];
|
||||
|
||||
// Skip if already in a cluster
|
||||
if (processedLocations.has(otherGroup.locationId)) continue;
|
||||
|
||||
// Check if locations are close enough to cluster
|
||||
if (
|
||||
arePointsWithinDistance(
|
||||
group.locationMarker.position,
|
||||
otherGroup.locationMarker.position,
|
||||
distanceThreshold,
|
||||
)
|
||||
) {
|
||||
clusterMarkers.push(otherGroup.locationMarker);
|
||||
clusterMarkers.push(...otherGroup.stationMarkers);
|
||||
locationIds.add(otherGroup.locationId);
|
||||
processedLocations.add(otherGroup.locationId);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a cluster if we have more than one location
|
||||
if (locationIds.size > 1) {
|
||||
clusters.push({
|
||||
identifier: clusters.length.toString(),
|
||||
markers: clusterMarkers,
|
||||
type: 'mixed',
|
||||
count: clusterMarkers.length,
|
||||
position: calculateCenter(clusterMarkers.map((m) => m.position)),
|
||||
color: 'var(--grayscale-color-1)',
|
||||
});
|
||||
} else {
|
||||
// Just return the location if it's not clustered
|
||||
clusters.push({
|
||||
identifier: clusters.length.toString(),
|
||||
markers: clusterMarkers,
|
||||
type: 'location',
|
||||
count: clusterMarkers.length,
|
||||
position: group.locationMarker.position,
|
||||
color: group.locationMarker.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Return any markers that aren't part of a location group
|
||||
const ungroupedMarkers = visibleMarkers.filter(
|
||||
(marker) => !marker.locationId || !processedLocations.has(marker.locationId),
|
||||
);
|
||||
|
||||
return [...clusters, ...ungroupedMarkers];
|
||||
}
|
||||
|
||||
// Helper function to create elements based on location grouping
|
||||
function createLocationBasedElements(
|
||||
visibleMarkers: MapMarkerData[],
|
||||
locations: MapProps['locations'] = [],
|
||||
): (ClusterInfo | MapMarkerData)[] {
|
||||
// Create location groups
|
||||
const locationGroups = createLocationGroups(visibleMarkers, locations);
|
||||
|
||||
const elements: (ClusterInfo | MapMarkerData)[] = [];
|
||||
const processedStationIds = new Set<string>();
|
||||
|
||||
// Add location markers for complete location groups
|
||||
locationGroups.forEach((group) => {
|
||||
// if (group.isComplete) {
|
||||
// Add the location marker
|
||||
elements.push(group.locationMarker);
|
||||
|
||||
// Mark these stations as processed
|
||||
group.stationMarkers.forEach((station) => {
|
||||
processedStationIds.add(station.identifier);
|
||||
});
|
||||
// } else {
|
||||
// // For incomplete location groups, just add the individual station markers
|
||||
// group.stationMarkers.forEach((station) => {
|
||||
// elements.push(station);
|
||||
// processedStationIds.add(station.identifier);
|
||||
// });
|
||||
// }
|
||||
});
|
||||
|
||||
// Add any markers that weren't in a location group
|
||||
visibleMarkers.forEach((marker) => {
|
||||
if (!processedStationIds.has(marker.identifier)) {
|
||||
elements.push(marker);
|
||||
}
|
||||
});
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
// Helper function to create location groups from markers
|
||||
function createLocationGroups(
|
||||
markers: MapMarkerData[],
|
||||
locations: MapProps['locations'] = [],
|
||||
): LocationGroup[] {
|
||||
if (!locations || locations.length === 0) return [];
|
||||
|
||||
// Group station markers by location
|
||||
const markersByLocation = new Map<string, MapMarkerData[]>();
|
||||
|
||||
markers.forEach((marker) => {
|
||||
if (marker.type === 'station' && marker.locationId) {
|
||||
const locationMarkers = markersByLocation.get(marker.locationId) || [];
|
||||
locationMarkers.push(marker);
|
||||
markersByLocation.set(marker.locationId, locationMarkers);
|
||||
}
|
||||
});
|
||||
|
||||
// Create location groups
|
||||
return Array.from(markersByLocation.entries())
|
||||
.map(([locationId, stationMarkers]) => {
|
||||
const location = locations.find((l) => l.id!.toString() === locationId);
|
||||
if (!location || !location.coordinates) return null;
|
||||
|
||||
// Create a marker for this location
|
||||
const locationMarker: MapMarkerData = {
|
||||
position: {
|
||||
lat: location.coordinates.coordinates[1],
|
||||
lng: location.coordinates.coordinates[0],
|
||||
},
|
||||
identifier: location.id!.toString(),
|
||||
type: 'location',
|
||||
status: determineLocationStatus(location),
|
||||
color: determineLocationColor(determineLocationStatus(location)),
|
||||
};
|
||||
|
||||
// Check if all stations from this location are present in the markers
|
||||
const totalStationsInLocation = location.chargingPool?.length || 0;
|
||||
const isComplete = stationMarkers.length === totalStationsInLocation;
|
||||
|
||||
return {
|
||||
locationId,
|
||||
locationMarker,
|
||||
stationMarkers,
|
||||
isComplete,
|
||||
};
|
||||
})
|
||||
.filter((group): group is LocationGroup => group !== null);
|
||||
}
|
||||
|
||||
// Helper function to calculate the distance threshold based on map bounds
|
||||
function calculateDistanceThreshold(bounds: google.maps.LatLngBounds): number {
|
||||
const ne = bounds.getNorthEast();
|
||||
const sw = bounds.getSouthWest();
|
||||
|
||||
// Calculate diagonal distance of the visible map area
|
||||
const diagonalDistance = calculateDistance(ne.lat(), ne.lng(), sw.lat(), sw.lng());
|
||||
|
||||
// Return a percentage of the diagonal as the threshold
|
||||
return diagonalDistance * 0.05; // 5% of diagonal distance
|
||||
}
|
||||
|
||||
// Helper function to check if two points are within a certain distance
|
||||
function arePointsWithinDistance(
|
||||
p1: google.maps.LatLngLiteral,
|
||||
p2: google.maps.LatLngLiteral,
|
||||
threshold: number,
|
||||
): boolean {
|
||||
const distance = calculateDistance(p1.lat, p1.lng, p2.lat, p2.lng);
|
||||
return distance <= threshold;
|
||||
}
|
||||
|
||||
// Helper function to calculate distance between two coordinates in km (Haversine formula)
|
||||
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371; // Radius of the earth in km
|
||||
const dLat = deg2rad(lat2 - lat1);
|
||||
const dLon = deg2rad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
function deg2rad(deg: number): number {
|
||||
return deg * (Math.PI / 180);
|
||||
}
|
||||
|
||||
// Helper function to calculate the center of a group of points
|
||||
function calculateCenter(positions: google.maps.LatLngLiteral[]): google.maps.LatLngLiteral {
|
||||
if (positions.length === 0) {
|
||||
return { lat: 0, lng: 0 };
|
||||
}
|
||||
|
||||
if (positions.length === 1) {
|
||||
return positions[0];
|
||||
}
|
||||
|
||||
const sumLat = positions.reduce((sum, pos) => sum + pos.lat, 0);
|
||||
const sumLng = positions.reduce((sum, pos) => sum + pos.lng, 0);
|
||||
|
||||
return {
|
||||
lat: sumLat / positions.length,
|
||||
lng: sumLng / positions.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to determine a location's status based on its charging stations
|
||||
function determineLocationStatus(location: LocationDto): 'online' | 'offline' | 'partial' {
|
||||
if (!location.chargingPool || location.chargingPool.length === 0) {
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
const onlineCount = location.chargingPool.filter((station: any) => station.isOnline).length;
|
||||
|
||||
if (onlineCount === location.chargingPool.length) {
|
||||
return 'online';
|
||||
} else if (onlineCount === 0) {
|
||||
return 'offline';
|
||||
} else {
|
||||
return 'partial';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to determine a location's color based on its status
|
||||
function determineLocationColor(status: 'online' | 'offline' | 'partial'): string {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'var(--primary-color-1)';
|
||||
case 'partial':
|
||||
return 'var(--grayscale-color-2)';
|
||||
case 'offline':
|
||||
default:
|
||||
return 'var(--secondary-color-2)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { APIProvider, ColorScheme, Map } from '@vis.gl/react-google-maps';
|
||||
import config from '@lib/utils/config';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { getGoogleMapsApiKey, setGoogleMapsApiKey } from '@lib/utils/store/maps.slice';
|
||||
import { getGoogleMapsApiKeyAction } from '@lib/server/actions/map/getGoogleMapsApiKeyAction';
|
||||
import type { LocationDto } from '@citrineos/base';
|
||||
import { ClusteredLocationMarkers } from '@lib/client/components/map/map.clusters';
|
||||
import { Skeleton } from '@lib/client/components/ui/skeleton';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
const defaultCenter = {
|
||||
lat: config.defaultMapCenterLatitude!,
|
||||
lng: config.defaultMapCenterLongitude!,
|
||||
};
|
||||
|
||||
export const LocationMapV2 = ({ locations }: { locations: LocationDto[] }) => {
|
||||
const dispatch = useDispatch();
|
||||
const apiKey = useSelector(getGoogleMapsApiKey);
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKey === undefined) {
|
||||
getGoogleMapsApiKeyAction().then((result) =>
|
||||
dispatch(setGoogleMapsApiKey(result.success ? result.data : '')),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return apiKey === undefined ? (
|
||||
<Skeleton className="size=full" />
|
||||
) : (
|
||||
<div className="size-full">
|
||||
<APIProvider apiKey={apiKey ?? ''}>
|
||||
<Map
|
||||
mapId={config.googleMapsOverviewMapId}
|
||||
defaultZoom={4}
|
||||
defaultCenter={defaultCenter}
|
||||
gestureHandling="cooperative"
|
||||
disableDefaultUI
|
||||
zoomControl
|
||||
colorScheme={theme === 'dark' ? ColorScheme.DARK : ColorScheme.LIGHT}
|
||||
>
|
||||
<ClusteredLocationMarkers
|
||||
locations={locations.filter((location) => location.coordinates)}
|
||||
/>
|
||||
</Map>
|
||||
</APIProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { MarkerIconProps } from '@lib/client/components/map/types';
|
||||
|
||||
export const MarkerIconCircle: React.FC<MarkerIconProps> = ({
|
||||
style,
|
||||
fillColor = 'currentColor',
|
||||
status = 'offline',
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
backgroundColor: fillColor,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
<LocationIcon width={'70%'} height={'100%'} color={'white'} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LocationIcon: React.FC<{
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
color?: string;
|
||||
}> = ({ width = 24, height = 24, color = 'white' }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChargingStationIcon: React.FC<{
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
color?: string;
|
||||
status?: 'online' | 'offline' | 'partial';
|
||||
}> = ({ width = 24, height = 24, color = 'white', status = 'offline' }) => {
|
||||
// Add a subtle indicator of status via the bolt color
|
||||
const boltColor = status === 'online' ? '#4CAF50' : status === 'partial' ? '#FFC107' : '#757575';
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.77 7.23L19.78 7.22L16.06 3.5L15 4.56L17.11 6.67C16.17 7.03 15.5 7.93 15.5 9C15.5 10.38 16.62 11.5 18 11.5C18.36 11.5 18.69 11.42 19 11.29V18.5C19 19.05 18.55 19.5 18 19.5C17.45 19.5 17 19.05 17 18.5V14C17 12.9 16.1 12 15 12H14V5C14 3.9 13.1 3 12 3H6C4.9 3 4 3.9 4 5V21H14V13.5H15.5V18.5C15.5 19.88 16.62 21 18 21C19.38 21 20.5 19.88 20.5 18.5V9C20.5 8.31 20.22 7.68 19.77 7.23ZM12 10H6V5H12V10Z"
|
||||
fill={color}
|
||||
/>
|
||||
<path
|
||||
d="M18 10C18.55 10 19 9.55 19 9C19 8.45 18.55 8 18 8C17.45 8 17 8.45 17 9C17 9.55 17.45 10 18 10Z"
|
||||
fill={boltColor}
|
||||
/>
|
||||
<path
|
||||
d="M8 16H10V14H8V16ZM8 13H10V11H8V13ZM8 19H10V17H8V19ZM12 16H14V14H12V16ZM12 13H14V11H12V13ZM12 19H14V17H12V19Z"
|
||||
fill={boltColor}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const ClusterIcon: React.FC<{
|
||||
count: number;
|
||||
type?: 'station' | 'location' | 'mixed';
|
||||
color?: string;
|
||||
}> = ({ count, type = 'location', color = 'var(--color-primary)' }) => {
|
||||
const size = Math.min(60, Math.max(40, 30 + Math.log10(count) * 10));
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: color,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
{type === 'station' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: -5,
|
||||
right: -5,
|
||||
backgroundColor: '#4CAF50',
|
||||
borderRadius: '50%',
|
||||
width: 16,
|
||||
height: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid white',
|
||||
}}
|
||||
>
|
||||
<ChargingStationIcon width={10} height={10} />
|
||||
</div>
|
||||
)}
|
||||
{type === 'location' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: -5,
|
||||
right: -5,
|
||||
backgroundColor: '#2196F3',
|
||||
borderRadius: '50%',
|
||||
width: 16,
|
||||
height: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid white',
|
||||
}}
|
||||
>
|
||||
<LocationIcon width={10} height={10} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import type { LocationDto } from '@citrineos/base';
|
||||
import type { GeoPoint } from '@lib/utils/GeoPoint';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface MapMarkerData {
|
||||
position: google.maps.LatLngLiteral;
|
||||
identifier: string;
|
||||
type: 'station' | 'location' | 'mixed';
|
||||
locationId?: string;
|
||||
status?: 'online' | 'offline' | 'partial';
|
||||
color?: string;
|
||||
reactContent?: ReactNode;
|
||||
}
|
||||
|
||||
export interface BaseMapMarkerProps {
|
||||
position: google.maps.LatLngLiteral;
|
||||
identifier: string;
|
||||
reactContent?: ReactNode;
|
||||
onClick?: (id: string, type: 'station' | 'location' | 'mixed') => void;
|
||||
isSelected?: boolean;
|
||||
color?: string;
|
||||
status?: 'online' | 'offline' | 'partial';
|
||||
}
|
||||
|
||||
export interface StationMapMarkerProps extends BaseMapMarkerProps {
|
||||
type: 'station';
|
||||
locationId?: string;
|
||||
}
|
||||
|
||||
export interface LocationMapMarkerProps extends BaseMapMarkerProps {
|
||||
type: 'location';
|
||||
}
|
||||
|
||||
export interface ClusterMapMarkerProps extends BaseMapMarkerProps {
|
||||
type: 'mixed';
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type MapMarkerProps = StationMapMarkerProps | LocationMapMarkerProps | ClusterMapMarkerProps;
|
||||
|
||||
export interface MapProps {
|
||||
locations?: LocationDto[];
|
||||
defaultCenter?: google.maps.LatLngLiteral;
|
||||
zoom?: number;
|
||||
onMarkerClick?: (id: string, type: 'station' | 'location' | 'mixed') => void;
|
||||
selectedMarkerId?: string;
|
||||
clusterByLocation?: boolean;
|
||||
}
|
||||
|
||||
export interface LocationPickerMapProps {
|
||||
point?: GeoPoint;
|
||||
defaultCenter?: google.maps.LatLngLiteral;
|
||||
zoom?: number;
|
||||
onLocationSelect: (point: GeoPoint) => void;
|
||||
}
|
||||
|
||||
export interface MarkerIconProps {
|
||||
style?: React.CSSProperties;
|
||||
fillColor?: string;
|
||||
status?: 'online' | 'offline' | 'partial';
|
||||
}
|
||||
|
||||
export interface ClusterInfo extends MapMarkerData {
|
||||
markers: MapMarkerData[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Group of markers from the same location
|
||||
export interface LocationGroup {
|
||||
locationId: string;
|
||||
locationMarker: MapMarkerData;
|
||||
stationMarkers: MapMarkerData[];
|
||||
isComplete: boolean; // true if all stations from this location are present
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { type ChargingStationDto, type ConnectorDto } from '@citrineos/base';
|
||||
import { ConnectorProps, OCPP1_6, OCPPVersion } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { ComboboxFormField, SelectFormField } from '@lib/client/components/form/field';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import { CONNECTOR_LIST_FOR_STATION_QUERY } from '@lib/queries/connectors';
|
||||
import { ResourceType } from '@lib/utils/access.types';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useSelect } from '@refinedev/core';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface ChangeAvailabilityModalProps {
|
||||
station: ChargingStationDto;
|
||||
}
|
||||
|
||||
const ChangeAvailabilitySchema = z.object({
|
||||
type: z.enum(OCPP1_6.ChangeAvailabilityRequestType, {
|
||||
message: 'Please select an availability type',
|
||||
}),
|
||||
connectorId: z.number({
|
||||
message: 'Connector is required',
|
||||
}),
|
||||
});
|
||||
|
||||
type ChangeAvailabilityFormData = z.infer<typeof ChangeAvailabilitySchema>;
|
||||
|
||||
const availabilityTypes: OCPP1_6.ChangeAvailabilityRequestType[] = Object.keys(
|
||||
OCPP1_6.ChangeAvailabilityRequestType,
|
||||
) as OCPP1_6.ChangeAvailabilityRequestType[];
|
||||
|
||||
export const ChangeAvailabilityModal = ({ station }: ChangeAvailabilityModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ChangeAvailabilitySchema),
|
||||
defaultValues: {
|
||||
type: undefined,
|
||||
connectorId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { options, onSearch, query } = useSelect<ConnectorDto>({
|
||||
resource: ResourceType.CONNECTORS,
|
||||
optionLabel: 'connectorId',
|
||||
optionValue: 'connectorId',
|
||||
meta: {
|
||||
gqlQuery: CONNECTOR_LIST_FOR_STATION_QUERY,
|
||||
gqlVariables: {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
stationId: parsedStation.id,
|
||||
},
|
||||
},
|
||||
sorters: [{ field: ConnectorProps.connectorId, order: 'asc' }],
|
||||
pagination: { mode: 'off' },
|
||||
onSearch: (value: string) => {
|
||||
const connectorId = Number(value);
|
||||
if (!connectorId || !Number.isInteger(connectorId) || connectorId < 1) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
operator: 'or',
|
||||
value: [{ field: ConnectorProps.connectorId, operator: 'eq', value }],
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const onFinish = async (values: ChangeAvailabilityFormData) => {
|
||||
const data = {
|
||||
type: values.type,
|
||||
connectorId: values.connectorId,
|
||||
};
|
||||
|
||||
await triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/configuration/changeAvailability?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: OCPPVersion.OCPP1_6,
|
||||
});
|
||||
|
||||
form.reset({
|
||||
type: undefined,
|
||||
connectorId: undefined,
|
||||
});
|
||||
|
||||
dispatch(closeModal());
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={onFinish}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
label="Availability"
|
||||
name="type"
|
||||
options={availabilityTypes}
|
||||
required
|
||||
/>
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
label="Connector"
|
||||
name="connectorId"
|
||||
description="Connector IDs are serial integers starting at 1"
|
||||
options={options}
|
||||
onSearch={onSearch}
|
||||
placeholder="Select Connector"
|
||||
searchPlaceholder="Search Connectors"
|
||||
isLoading={query.isLoading}
|
||||
required
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { type ChargingStationDto, OCPPVersion } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormField } from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export const ChangeConfigurationSchema = z.object({
|
||||
key: z.string().min(1, 'Key is required'),
|
||||
value: z.string().min(1, 'Value is required'),
|
||||
});
|
||||
|
||||
export type ChangeConfigurationFormData = z.infer<typeof ChangeConfigurationSchema>;
|
||||
|
||||
export interface ChangeConfigurationModalProps {
|
||||
station: any;
|
||||
defaultConfiguration?: ChangeConfigurationFormData;
|
||||
onFinish?: () => void;
|
||||
}
|
||||
|
||||
export const ChangeConfigurationModal = ({
|
||||
station,
|
||||
defaultConfiguration,
|
||||
onFinish,
|
||||
}: ChangeConfigurationModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ChangeConfigurationSchema),
|
||||
defaultValues: {
|
||||
key: defaultConfiguration ? defaultConfiguration.key : '',
|
||||
value: defaultConfiguration ? defaultConfiguration.value : '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: ChangeConfigurationFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error(
|
||||
'Error: Cannot submit Change Configuration request because station ID is missing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
key: values.key,
|
||||
value: values.value,
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/configuration/changeConfiguration?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: OCPPVersion.OCPP1_6,
|
||||
}).then(() => {
|
||||
form.reset({
|
||||
key: '',
|
||||
value: '',
|
||||
});
|
||||
|
||||
onFinish?.();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={handleSubmit}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<FormField control={form.control} label="Key" name="key">
|
||||
<Input placeholder="Configuration key" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Value" name="value">
|
||||
<Input placeholder="Configuration value" />
|
||||
</FormField>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { ModalComponentType } from '@lib/client/components/modals/modal.types';
|
||||
|
||||
/**
|
||||
* Command definition for OCPP 1.6 commands
|
||||
*/
|
||||
export interface CommandDefinition {
|
||||
/** Display name shown in the UI */
|
||||
displayName: string;
|
||||
/** Modal component type for registration */
|
||||
modalType: ModalComponentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of all OCPP 1.6 commands
|
||||
*
|
||||
* This registry maps command identifiers to their modal types.
|
||||
* To add a new command:
|
||||
* 1. Add the modal component to src/lib/client/components/modals/index.tsx
|
||||
* 2. Add the corresponding ModalComponentType enum value
|
||||
* 3. Add a new entry to this registry with a unique key
|
||||
*/
|
||||
export const OCPP1_6_COMMANDS_REGISTRY: Record<string, CommandDefinition> = {
|
||||
'Change Availability': {
|
||||
displayName: 'Change Availability',
|
||||
modalType: ModalComponentType.changeAvailability16,
|
||||
},
|
||||
'Data Transfer': {
|
||||
displayName: 'Data Transfer',
|
||||
modalType: ModalComponentType.dataTransfer,
|
||||
},
|
||||
'Change Configuration': {
|
||||
displayName: 'Change Configuration',
|
||||
modalType: ModalComponentType.changeConfiguration16,
|
||||
},
|
||||
'Get Configuration': {
|
||||
displayName: 'Get Configuration',
|
||||
modalType: ModalComponentType.getConfiguration16,
|
||||
},
|
||||
'Get Diagnostics': {
|
||||
displayName: 'Get Diagnostics',
|
||||
modalType: ModalComponentType.getDiagnostics16,
|
||||
},
|
||||
'Trigger Message': {
|
||||
displayName: 'Trigger Message',
|
||||
modalType: ModalComponentType.triggerMessage16,
|
||||
},
|
||||
'Update Firmware': {
|
||||
displayName: 'Update Firmware',
|
||||
modalType: ModalComponentType.updateFirmware16,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all command keys in the registry
|
||||
*/
|
||||
export const getOCPP16CommandKeys = (): string[] => {
|
||||
return Object.keys(OCPP1_6_COMMANDS_REGISTRY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get command definition by key
|
||||
*/
|
||||
export const getOCPP16Command = (key: string): CommandDefinition | undefined => {
|
||||
return OCPP1_6_COMMANDS_REGISTRY[key];
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { type ChargingStationDto, OCPPVersion } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormField, nestedFormRowFlex } from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { AddArrayItemButton } from '@lib/client/components/form/add-array-item-button';
|
||||
import { RemoveArrayItemButton } from '@lib/client/components/form/remove-array-item-button';
|
||||
import { useFieldArray } from 'react-hook-form';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface GetConfigurationModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
export const GetConfigurationSchema = z.object({
|
||||
configurationKeys: z
|
||||
.array(
|
||||
z.object({
|
||||
configKey: z.string(),
|
||||
}),
|
||||
) // an array of objects to allow react-hook-form useFieldArray to work
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type GetConfigurationFormData = z.infer<typeof GetConfigurationSchema>;
|
||||
|
||||
export const GetConfigurationModal = ({ station }: GetConfigurationModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(GetConfigurationSchema),
|
||||
defaultValues: {
|
||||
configurationKeys: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'configurationKeys',
|
||||
});
|
||||
|
||||
const handleSubmit = (values: GetConfigurationFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error(
|
||||
'Error: Cannot submit Get Configuration request because station ID is missing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const keys =
|
||||
values.configurationKeys && values.configurationKeys.length > 0
|
||||
? [...new Set(values.configurationKeys.map((ck) => ck.configKey))]
|
||||
: null;
|
||||
|
||||
const data: any = {};
|
||||
|
||||
if (keys) {
|
||||
data.key = keys;
|
||||
}
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/configuration/getConfiguration?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: OCPPVersion.OCPP1_6,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={handleSubmit}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Optionally specify configuration keys to retrieve. Leave empty to get all configuration
|
||||
values.
|
||||
</div>
|
||||
|
||||
<AddArrayItemButton
|
||||
onAppendAction={() =>
|
||||
append({
|
||||
configKey: '',
|
||||
})
|
||||
}
|
||||
itemLabel="Key"
|
||||
/>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className={nestedFormRowFlex}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
label={`Key #${index + 1}`}
|
||||
name={`configurationKeys.${index}.configKey`}
|
||||
>
|
||||
<Input placeholder="Enter configuration key" />
|
||||
</FormField>
|
||||
|
||||
<RemoveArrayItemButton onRemoveAction={() => remove(index)} />
|
||||
</div>
|
||||
))}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type ChargingStationDto, OCPPVersion } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormField } from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
interface GetDiagnosticsModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
const GetDiagnosticsSchema = z.object({
|
||||
location: z.url('Must be a valid URL').min(1, 'Location is required').max(512),
|
||||
startTime: z.string().min(1).optional(),
|
||||
stopTime: z.string().min(1).optional(),
|
||||
retries: z.coerce.number<number>().int().min(0).optional(),
|
||||
retryInterval: z.coerce.number<number>().int().min(0).optional(),
|
||||
});
|
||||
|
||||
type GetDiagnosticsFormData = z.infer<typeof GetDiagnosticsSchema>;
|
||||
|
||||
export const GetDiagnosticsModal = ({ station }: GetDiagnosticsModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const location = 'http://localhost:4566/citrineos-s3-bucket/';
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(GetDiagnosticsSchema),
|
||||
defaultValues: {
|
||||
location,
|
||||
},
|
||||
});
|
||||
|
||||
const onFinish = async (values: GetDiagnosticsFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error('Error: Cannot submit Get Logs request because station ID is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
location: values.location,
|
||||
startTime: values.startTime ? new Date(values.startTime).toISOString() : undefined,
|
||||
stopTime: values.stopTime ? new Date(values.stopTime).toISOString() : undefined,
|
||||
...(values.retries !== undefined && { retries: values.retries }),
|
||||
...(values.retryInterval !== undefined && {
|
||||
retryInterval: values.retryInterval,
|
||||
}),
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/reporting/getDiagnostics?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: OCPPVersion.OCPP1_6,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
submitHandler={onFinish}
|
||||
loading={loading}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<FormField control={form.control} label="Location (URL)" name="location" required>
|
||||
<Input placeholder={location} type="url" />
|
||||
</FormField>
|
||||
<FormField control={form.control} label="Start Timestamp" name="startTime">
|
||||
<Input type="datetime-local" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Stop Timestamp" name="stopTime">
|
||||
<Input type="datetime-local" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Retries" name="retries">
|
||||
<Input type="number" placeholder="Number of retries" min="0" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Retry Interval" name="retryInterval">
|
||||
<Input type="number" placeholder="Retry interval in seconds" min="0" />
|
||||
</FormField>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ChargingStationDto } from '@citrineos/base';
|
||||
import { type ConnectorDto, ConnectorProps, OCPP1_6, OCPPVersion } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { ComboboxFormField, SelectFormField } from '@lib/client/components/form/field';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import { CONNECTOR_LIST_FOR_STATION_QUERY } from '@lib/queries/connectors';
|
||||
import { ResourceType } from '@lib/utils/access.types';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useSelect } from '@refinedev/core';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface TriggerMessageModalProps {
|
||||
station: ChargingStationDto;
|
||||
}
|
||||
|
||||
const TriggerMessageSchema = z.object({
|
||||
requestedMessage: z.enum(OCPP1_6.TriggerMessageRequestRequestedMessage, {
|
||||
message: 'Please select a message type',
|
||||
}),
|
||||
connectorId: z.number().optional(),
|
||||
});
|
||||
|
||||
type TriggerMessageFormData = z.infer<typeof TriggerMessageSchema>;
|
||||
|
||||
const triggerMessages = Object.keys(OCPP1_6.TriggerMessageRequestRequestedMessage);
|
||||
|
||||
export const TriggerMessageModal = ({ station }: TriggerMessageModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(TriggerMessageSchema),
|
||||
defaultValues: {
|
||||
requestedMessage: undefined,
|
||||
connectorId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { options, onSearch, query } = useSelect<ConnectorDto>({
|
||||
resource: ResourceType.CONNECTORS,
|
||||
optionLabel: 'connectorId',
|
||||
optionValue: 'connectorId',
|
||||
meta: {
|
||||
gqlQuery: CONNECTOR_LIST_FOR_STATION_QUERY,
|
||||
gqlVariables: {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
stationId: parsedStation.id,
|
||||
},
|
||||
},
|
||||
sorters: [{ field: ConnectorProps.connectorId, order: 'asc' }],
|
||||
pagination: { mode: 'off' },
|
||||
onSearch: (value: string) => {
|
||||
const connectorId = Number(value);
|
||||
if (!connectorId || !Number.isInteger(connectorId) || connectorId < 1) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
operator: 'or',
|
||||
value: [{ field: ConnectorProps.connectorId, operator: 'eq', value }],
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: TriggerMessageFormData) => {
|
||||
const data: any = {
|
||||
requestedMessage: values.requestedMessage,
|
||||
};
|
||||
|
||||
if (values.connectorId !== undefined) {
|
||||
data.connectorId = values.connectorId;
|
||||
}
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/configuration/triggerMessage?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: OCPPVersion.OCPP1_6,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={handleSubmit}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
name="requestedMessage"
|
||||
label="Requested Message"
|
||||
options={triggerMessages}
|
||||
placeholder="Select Message"
|
||||
required
|
||||
/>
|
||||
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
name="connectorId"
|
||||
label="Connector"
|
||||
options={options}
|
||||
onSearch={onSearch}
|
||||
placeholder="Search Connectors"
|
||||
isLoading={query.isLoading}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import type { ChargingStationDto } from '@citrineos/base';
|
||||
import { OCPPVersion } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormField } from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface UpdateFirmwareModalProps {
|
||||
station: ChargingStationDto;
|
||||
}
|
||||
|
||||
const UpdateFirmwareSchema = z.object({
|
||||
location: z.url('Must be a valid URL').min(1, 'Location is required').max(512),
|
||||
retrieveDate: z.string().min(1, 'Retrieve date is required'),
|
||||
retries: z.coerce.number<number>().int().min(0).optional(),
|
||||
retryInterval: z.coerce.number<number>().int().min(0).optional(),
|
||||
});
|
||||
|
||||
type UpdateFirmwareFormData = z.infer<typeof UpdateFirmwareSchema>;
|
||||
|
||||
export const UpdateFirmwareModal = ({ station }: UpdateFirmwareModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(UpdateFirmwareSchema),
|
||||
defaultValues: {
|
||||
location: '',
|
||||
retrieveDate: '',
|
||||
retries: undefined,
|
||||
retryInterval: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: UpdateFirmwareFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error('Error: Cannot submit Update Firmware request because station ID is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
location: values.location,
|
||||
retrieveDate: new Date(values.retrieveDate).toISOString(),
|
||||
...(values.retries !== undefined && { retries: values.retries }),
|
||||
...(values.retryInterval !== undefined && {
|
||||
retryInterval: values.retryInterval,
|
||||
}),
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/configuration/updateFirmware?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: OCPPVersion.OCPP1_6,
|
||||
}).then(() => {
|
||||
form.reset({
|
||||
location: '',
|
||||
retrieveDate: '',
|
||||
retries: undefined,
|
||||
retryInterval: undefined,
|
||||
});
|
||||
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={handleSubmit}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<FormField control={form.control} label="Location (URL)" name="location" required>
|
||||
<Input placeholder="https://example.com/firmware.bin" type="url" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Retrieve Date" name="retrieveDate" required>
|
||||
<Input type="datetime-local" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Retries" name="retries">
|
||||
<Input type="number" placeholder="Number of retries" min="0" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Retry Interval" name="retryInterval">
|
||||
<Input type="number" placeholder="Retry interval in seconds" min="0" />
|
||||
</FormField>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type ChargingStationDto, OCPP2_0_1 } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FormField, SelectFormField } from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { readFileContent, triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface CertificateSignedModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ACCEPTED_FILE_TYPES = ['.pem', '.id'];
|
||||
|
||||
const certificateSigningUses = Object.keys(OCPP2_0_1.CertificateSigningUseEnumType);
|
||||
|
||||
export const CertificateSignedSchema = z.object({
|
||||
certificateType: z.enum(OCPP2_0_1.CertificateSigningUseEnumType).optional(),
|
||||
certificate: z
|
||||
.custom<FileList>()
|
||||
.refine((files) => files?.length === 1, 'Certificate file is required')
|
||||
.refine(
|
||||
(files) => {
|
||||
const file = files?.[0];
|
||||
if (!file) return false;
|
||||
const extension = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
return ACCEPTED_FILE_TYPES.includes(extension);
|
||||
},
|
||||
`File must be one of: ${ACCEPTED_FILE_TYPES.join(', ')}`,
|
||||
)
|
||||
.refine((files) => files?.[0]?.size <= MAX_FILE_SIZE, 'File size must be less than 5MB'),
|
||||
});
|
||||
|
||||
export type CertificateSignedFormData = z.infer<typeof CertificateSignedSchema>;
|
||||
|
||||
export const CertificateSignedModal = ({ station }: CertificateSignedModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CertificateSignedSchema),
|
||||
defaultValues: {
|
||||
certificateType: undefined,
|
||||
certificate: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const fileRef = form.register('certificate');
|
||||
|
||||
const onFinish = (values: CertificateSignedFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error(
|
||||
'Error: Cannot submit Certificate Signed request because station ID is missing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = values.certificate[0];
|
||||
readFileContent(file)
|
||||
.then((fileContent) => {
|
||||
const data = {
|
||||
certificateType: values.certificateType,
|
||||
certificateChain: fileContent,
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/certificates/certificateSigned?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset({
|
||||
certificateType: undefined,
|
||||
certificate: undefined,
|
||||
});
|
||||
dispatch(closeModal());
|
||||
});
|
||||
})
|
||||
.catch((err) => console.error('Error during submission:', err));
|
||||
};
|
||||
|
||||
const handleFormSubmit = form.handleSubmit(onFinish);
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
submitHandler={handleFormSubmit}
|
||||
loading={loading}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<FormField control={form.control} label="Certificate File" name="certificate" required>
|
||||
<Input type="file" accept={ACCEPTED_FILE_TYPES.join(',')} {...fileRef} />
|
||||
</FormField>
|
||||
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
label="Certificate Type"
|
||||
name="certificateType"
|
||||
options={certificateSigningUses}
|
||||
placeholder="Select Certificate Type"
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { type ChargingStationDto } from '@citrineos/base';
|
||||
import { OCPP2_0_1 } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { ComboboxFormField } from '@lib/client/components/form/field';
|
||||
import { ConnectorSelector } from '@lib/client/components/modals/shared/connector-selector/connector.selector';
|
||||
import { EvseSelector } from '@lib/client/components/modals/shared/evse-selector/evse.selector';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { z } from 'zod';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface ChangeAvailabilityModalProps {
|
||||
station: ChargingStationDto;
|
||||
}
|
||||
|
||||
const ChangeAvailabilitySchema = z.object({
|
||||
operationalStatus: z.enum(OCPP2_0_1.OperationalStatusEnumType, {
|
||||
message: 'Please select an operational status',
|
||||
}),
|
||||
evse: z.string().optional(), // { id, evseTypeId }
|
||||
connectorId: z.number().optional(),
|
||||
});
|
||||
|
||||
type ChangeAvailabilityFormData = z.infer<typeof ChangeAvailabilitySchema>;
|
||||
|
||||
const statuses: OCPP2_0_1.OperationalStatusEnumType[] = Object.keys(
|
||||
OCPP2_0_1.OperationalStatusEnumType,
|
||||
) as OCPP2_0_1.OperationalStatusEnumType[];
|
||||
|
||||
export const ChangeAvailabilityModal = ({ station }: ChangeAvailabilityModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ChangeAvailabilitySchema),
|
||||
defaultValues: {
|
||||
operationalStatus: undefined,
|
||||
evse: undefined,
|
||||
connectorId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: ChangeAvailabilityFormData) => {
|
||||
const data: any = {
|
||||
operationalStatus: values.operationalStatus,
|
||||
};
|
||||
|
||||
if (values.evse !== undefined) {
|
||||
const parsedEvse = JSON.parse(values.evse);
|
||||
|
||||
data.evse = {
|
||||
id: parsedEvse.evseTypeId,
|
||||
...(values.connectorId !== undefined ? { connectorId: values.connectorId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/configuration/changeAvailability?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
const handleEvseSelection = (value: any) => {
|
||||
form.setValue('evse', value);
|
||||
// Reset connector when EVSE changes
|
||||
form.setValue('connectorId', undefined);
|
||||
};
|
||||
|
||||
const handleConnectorSelection = (value: number) => {
|
||||
form.setValue('connectorId', value);
|
||||
};
|
||||
|
||||
const selectedEvseId = form.watch('evse');
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
submitHandler={handleSubmit}
|
||||
loading={loading}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
name="operationalStatus"
|
||||
label="Operational Status"
|
||||
options={statuses.map((status) => ({
|
||||
label: status,
|
||||
value: status,
|
||||
}))}
|
||||
placeholder="Select Status"
|
||||
required
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="evse"
|
||||
render={({ field }) => (
|
||||
<EvseSelector
|
||||
station={parsedStation}
|
||||
value={field.value ?? undefined}
|
||||
onSelect={handleEvseSelection}
|
||||
isOptional
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="connectorId"
|
||||
render={({ field }) => (
|
||||
<ConnectorSelector
|
||||
station={parsedStation}
|
||||
evseId={selectedEvseId ? JSON.parse(selectedEvseId).id : undefined}
|
||||
value={field.value ?? undefined}
|
||||
onSelect={handleConnectorSelection}
|
||||
isOptional
|
||||
requiresEvseId
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type ChargingStationDto } from '@citrineos/base';
|
||||
import { Button } from '@lib/client/components/ui/button';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface ClearCacheModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
export const ClearCacheModal = ({ station }: ClearCacheModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error('Error: Cannot submit Clear Cache request because station ID is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
await triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/evdriver/clearCache?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data: {},
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
});
|
||||
|
||||
dispatch(closeModal());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>
|
||||
This will send a Clear Cache request to the charging station. The station will clear its
|
||||
authorization cache.
|
||||
</p>
|
||||
<p className="mt-2">Do you want to proceed?</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => dispatch(closeModal())}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleSubmit} disabled={loading}>
|
||||
{loading ? 'Clearing...' : 'Clear Cache'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { ModalComponentType } from '@lib/client/components/modals/modal.types';
|
||||
|
||||
/**
|
||||
* Command definition for OCPP 2.0.1 commands
|
||||
*/
|
||||
export interface CommandDefinition {
|
||||
/** Display name shown in the UI */
|
||||
displayName: string;
|
||||
/** Modal component type for registration */
|
||||
modalType: ModalComponentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of all OCPP 2.0.1 commands
|
||||
*
|
||||
* This registry maps command identifiers to their modal types.
|
||||
* To add a new command:
|
||||
* 1. Add the modal component to src/lib/client/components/modals/index.tsx
|
||||
* 2. Add the corresponding ModalComponentType enum value
|
||||
* 3. Add a new entry to this registry with a unique key
|
||||
*/
|
||||
export const OCPP2_0_1_COMMANDS_REGISTRY: Record<string, CommandDefinition> = {
|
||||
'Certificate Signed': {
|
||||
displayName: 'Certificate Signed',
|
||||
modalType: ModalComponentType.certificateSigned,
|
||||
},
|
||||
'Change Availability': {
|
||||
displayName: 'Change Availability',
|
||||
modalType: ModalComponentType.changeAvailability201,
|
||||
},
|
||||
'Clear Cache': {
|
||||
displayName: 'Clear Cache',
|
||||
modalType: ModalComponentType.clearCache,
|
||||
},
|
||||
'Customer Information': {
|
||||
displayName: 'Customer Information',
|
||||
modalType: ModalComponentType.customerInformation,
|
||||
},
|
||||
'Data Transfer': {
|
||||
displayName: 'Data Transfer',
|
||||
modalType: ModalComponentType.dataTransfer,
|
||||
},
|
||||
'Delete Certificate': {
|
||||
displayName: 'Delete Certificate',
|
||||
modalType: ModalComponentType.deleteCertificate,
|
||||
},
|
||||
'Delete Station Network Profiles': {
|
||||
displayName: 'Delete Station Network Profiles',
|
||||
modalType: ModalComponentType.deleteStationNetworkProfiles,
|
||||
},
|
||||
'Get Base Report': {
|
||||
displayName: 'Get Base Report',
|
||||
modalType: ModalComponentType.getBaseReport,
|
||||
},
|
||||
'Get Installed Certificate IDs': {
|
||||
displayName: 'Get Installed Certificate IDs',
|
||||
modalType: ModalComponentType.getInstalledCertificateIds,
|
||||
},
|
||||
'Get Logs': {
|
||||
displayName: 'Get Logs',
|
||||
modalType: ModalComponentType.getLogs,
|
||||
},
|
||||
'Get Transaction Status': {
|
||||
displayName: 'Get Transaction Status',
|
||||
modalType: ModalComponentType.getTransactionStatus,
|
||||
},
|
||||
'Get Variables': {
|
||||
displayName: 'Get Variables',
|
||||
modalType: ModalComponentType.getVariables,
|
||||
},
|
||||
'Install Certificate': {
|
||||
displayName: 'Install Certificate',
|
||||
modalType: ModalComponentType.installCertificate,
|
||||
},
|
||||
'Set Network Profile': {
|
||||
displayName: 'Set Network Profile',
|
||||
modalType: ModalComponentType.setNetworkProfile,
|
||||
},
|
||||
'Set Variables': {
|
||||
displayName: 'Set Variables',
|
||||
modalType: ModalComponentType.setVariables,
|
||||
},
|
||||
'Trigger Message': {
|
||||
displayName: 'Trigger Message',
|
||||
modalType: ModalComponentType.triggerMessage201,
|
||||
},
|
||||
'Unlock Connector': {
|
||||
displayName: 'Unlock Connector',
|
||||
modalType: ModalComponentType.unlockConnector,
|
||||
},
|
||||
'Update Auth Password': {
|
||||
displayName: 'Update Auth Password',
|
||||
modalType: ModalComponentType.updateAuthPassword,
|
||||
},
|
||||
'Update Firmware': {
|
||||
displayName: 'Update Firmware',
|
||||
modalType: ModalComponentType.updateFirmware201,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all command keys in the registry
|
||||
*/
|
||||
export const getOCPP201CommandKeys = (): string[] => {
|
||||
return Object.keys(OCPP2_0_1_COMMANDS_REGISTRY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get command definition by key
|
||||
*/
|
||||
export const getOCPP201Command = (key: string): CommandDefinition | undefined => {
|
||||
return OCPP2_0_1_COMMANDS_REGISTRY[key];
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type AuthorizationDto, type ChargingStationDto } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { CheckboxFormField, ComboboxFormField, FormField } from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import { AUTHORIZATIONS_LIST_QUERY } from '@lib/queries/authorizations';
|
||||
import { ResourceType } from '@lib/utils/access.types';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useSelect } from '@refinedev/core';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface CustomerInformationModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
export const CustomerInformationSchema = z.object({
|
||||
requestId: z.coerce.number<number>().int().positive('Request ID must be a positive number'),
|
||||
report: z.boolean(),
|
||||
clear: z.boolean(),
|
||||
customerIdentifier: z.string().optional(),
|
||||
authorization: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CustomerInformationFormData = z.infer<typeof CustomerInformationSchema>;
|
||||
|
||||
export const CustomerInformationModal = ({ station }: CustomerInformationModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CustomerInformationSchema),
|
||||
defaultValues: {
|
||||
requestId: 1,
|
||||
report: false,
|
||||
clear: false,
|
||||
customerIdentifier: '',
|
||||
authorization: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { options, onSearch, query } = useSelect<AuthorizationDto>({
|
||||
resource: ResourceType.AUTHORIZATIONS,
|
||||
optionLabel: 'idToken',
|
||||
optionValue: (auth) => JSON.stringify(auth),
|
||||
meta: {
|
||||
gqlQuery: AUTHORIZATIONS_LIST_QUERY,
|
||||
gqlVariables: {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
pagination: { mode: 'off' },
|
||||
});
|
||||
|
||||
const onFinish = (values: CustomerInformationFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error(
|
||||
'Error: Cannot submit Customer Information request because station ID is missing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
requestId: values.requestId,
|
||||
report: values.report,
|
||||
clear: values.clear,
|
||||
customerIdentifier: values.customerIdentifier || undefined,
|
||||
};
|
||||
|
||||
if (values.authorization) {
|
||||
const authorization = JSON.parse(values.authorization);
|
||||
|
||||
payload.idToken = {
|
||||
idToken: authorization.idToken,
|
||||
type: authorization.idTokenType,
|
||||
additionalInfo: authorization.additionalInfo,
|
||||
};
|
||||
}
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/reporting/customerInformation?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data: payload,
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset({
|
||||
requestId: 1,
|
||||
report: false,
|
||||
clear: false,
|
||||
customerIdentifier: '',
|
||||
authorization: '',
|
||||
});
|
||||
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormSubmit = form.handleSubmit(onFinish);
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={handleFormSubmit}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<FormField control={form.control} label="Request ID" name="requestId" required>
|
||||
<Input type="number" placeholder="Enter request ID" />
|
||||
</FormField>
|
||||
|
||||
<CheckboxFormField control={form.control} label="Report" name="report" />
|
||||
|
||||
<CheckboxFormField control={form.control} label="Clear" name="clear" />
|
||||
|
||||
<FormField control={form.control} label="Customer Identifier" name="customerIdentifier">
|
||||
<Input placeholder="Enter customer identifier" />
|
||||
</FormField>
|
||||
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
name="authorization"
|
||||
label="Authorization"
|
||||
options={options}
|
||||
onSearch={onSearch}
|
||||
placeholder="Search Authorizations"
|
||||
isLoading={query.isLoading}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ChargingStationDto,
|
||||
type InstalledCertificateDto,
|
||||
InstalledCertificateProps,
|
||||
} from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ComboboxFormField } from '@lib/client/components/form/field';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import { INSTALLED_CERTIFICATE_LIST_QUERY } from '@lib/queries/installed.certificates';
|
||||
import { ResourceType } from '@lib/utils/access.types';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useSelect } from '@refinedev/core';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { toast } from 'sonner';
|
||||
import z from 'zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface DeleteCertificateModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
export const DeleteCertificateSchema = z.object({
|
||||
certificate: z.string().min(1, 'Certificate is required'),
|
||||
});
|
||||
|
||||
export type DeleteCertificateFormData = z.infer<typeof DeleteCertificateSchema>;
|
||||
|
||||
export const DeleteCertificateModal = ({ station }: DeleteCertificateModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(DeleteCertificateSchema),
|
||||
defaultValues: {
|
||||
certificate: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { options, onSearch, query } = useSelect<InstalledCertificateDto>({
|
||||
resource: ResourceType.INSTALLED_CERTIFICATES,
|
||||
optionLabel: 'serialNumber',
|
||||
optionValue: (cert) => JSON.stringify(cert),
|
||||
meta: {
|
||||
gqlQuery: INSTALLED_CERTIFICATE_LIST_QUERY,
|
||||
gqlVariables: {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
filters: [
|
||||
{
|
||||
field: InstalledCertificateProps.ocppConnectionName,
|
||||
operator: 'eq',
|
||||
value: parsedStation.ocppConnectionName,
|
||||
},
|
||||
],
|
||||
pagination: { mode: 'off' },
|
||||
});
|
||||
|
||||
const selectedCertificate = form.watch('certificate');
|
||||
|
||||
const onFinish = async (values: DeleteCertificateFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error(
|
||||
'Error: Cannot submit Delete Certificate request because station ID is missing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!values.certificate) {
|
||||
toast.error('Please select a certificate');
|
||||
return;
|
||||
}
|
||||
|
||||
const certificate = JSON.parse(values.certificate);
|
||||
|
||||
if (parsedStation.ocppConnectionName !== certificate.ocppConnectionName) {
|
||||
toast.error('This certificate does not belong to this station');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
certificateHashData: {
|
||||
hashAlgorithm: certificate.hashAlgorithm,
|
||||
issuerNameHash: certificate.issuerNameHash,
|
||||
issuerKeyHash: certificate.issuerKeyHash,
|
||||
serialNumber: certificate.serialNumber,
|
||||
},
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/certificates/deleteCertificate?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset({
|
||||
certificate: '',
|
||||
});
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
const renderCertificateInformation = (stringifiedCertificate: string) => {
|
||||
if (!stringifiedCertificate) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const certificate = JSON.parse(stringifiedCertificate);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-md bg-muted p-4 text-sm">
|
||||
<span>
|
||||
<span className="font-semibold">Hash Algorithm:</span> {certificate.hashAlgorithm}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
submitHandler={onFinish}
|
||||
loading={loading}
|
||||
submitButtonVariant={FormButtonVariants.delete}
|
||||
submitButtonLabel="Delete Certificate"
|
||||
hideCancel
|
||||
>
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
name="certificate"
|
||||
label="Installed Certificate"
|
||||
options={options}
|
||||
onSearch={onSearch}
|
||||
placeholder="Search Certificates by Serial Number"
|
||||
isLoading={query.isLoading}
|
||||
required
|
||||
/>
|
||||
|
||||
{renderCertificateInformation(selectedCertificate)}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type ChargingStationDto, HttpMethod } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FormField, nestedFormRowFlex } from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { useFieldArray } from 'react-hook-form';
|
||||
import { AddArrayItemButton } from '@lib/client/components/form/add-array-item-button';
|
||||
import { RemoveArrayItemButton } from '@lib/client/components/form/remove-array-item-button';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
|
||||
export interface DeleteStationNetworkProfilesModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
export const DeleteStationNetworkProfilesSchema = z.object({
|
||||
configurationSlots: z
|
||||
.array(
|
||||
z.object({
|
||||
slot: z.coerce.number<number>().int().positive(),
|
||||
}),
|
||||
) // an array of objects to allow react-hook-form useFieldArray to work
|
||||
.min(1, 'At least one configuration slot is required'),
|
||||
});
|
||||
|
||||
export type DeleteStationNetworkProfilesFormData = z.infer<
|
||||
typeof DeleteStationNetworkProfilesSchema
|
||||
>;
|
||||
|
||||
export const DeleteStationNetworkProfilesModal = ({
|
||||
station,
|
||||
}: DeleteStationNetworkProfilesModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(DeleteStationNetworkProfilesSchema),
|
||||
defaultValues: {
|
||||
configurationSlots: [{ slot: 0 }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'configurationSlots',
|
||||
});
|
||||
|
||||
const onFinish = async (values: DeleteStationNetworkProfilesFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error(
|
||||
'Error: Cannot submit Delete Station Network Profiles request because station ID is missing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueSlots = [...new Set(values.configurationSlots.map((cs) => cs.slot))];
|
||||
|
||||
let url = `/configuration/serverNetworkProfile?ocppConnectionName=${parsedStation.ocppConnectionName}`;
|
||||
for (const configurationSlot of uniqueSlots) {
|
||||
url += `&configurationSlot=${configurationSlot}`;
|
||||
}
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation>({
|
||||
url: url,
|
||||
data: undefined,
|
||||
setLoading,
|
||||
ocppVersion: null,
|
||||
method: HttpMethod.Delete,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={onFinish}
|
||||
submitButtonVariant={FormButtonVariants.delete}
|
||||
submitButtonLabel="Delete Profiles"
|
||||
hideCancel
|
||||
>
|
||||
<AddArrayItemButton
|
||||
onAppendAction={() =>
|
||||
append({
|
||||
slot: 0,
|
||||
})
|
||||
}
|
||||
itemLabel="Slot"
|
||||
/>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className={nestedFormRowFlex}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
label={`Slot #${index + 1}`}
|
||||
name={`configurationSlots.${index}.slot`}
|
||||
>
|
||||
<Input type="number" placeholder="Enter slot number" min="1" />
|
||||
</FormField>
|
||||
|
||||
<RemoveArrayItemButton onRemoveAction={() => remove(index)} />
|
||||
</div>
|
||||
))}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type ChargingStationDto, OCPP2_0_1 } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FormField, SelectFormField } from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import { CHARGING_STATION_SEQUENCES_GET_QUERY } from '@lib/queries/charging.station.sequences';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useApiUrl, useCustom, useGetIdentity } from '@refinedev/core';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface GetBaseReportModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
const GetBaseReportSchema = z.object({
|
||||
requestId: z.coerce.number<number>().int().positive('Request ID must be a positive number'),
|
||||
reportBase: z.enum(OCPP2_0_1.ReportBaseEnumType, {
|
||||
message: 'Report Base is required',
|
||||
}),
|
||||
});
|
||||
|
||||
type GetBaseReportFormData = z.infer<typeof GetBaseReportSchema>;
|
||||
|
||||
const reportBaseTypes = Object.keys(OCPP2_0_1.ReportBaseEnumType);
|
||||
|
||||
export const GetBaseReportModal = ({ station }: GetBaseReportModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const apiUrl = useApiUrl();
|
||||
const {
|
||||
query: { data: requestIdResponse, isLoading: isLoadingRequestId },
|
||||
} = useCustom<any>({
|
||||
url: `${apiUrl}`,
|
||||
method: 'post',
|
||||
config: {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
operation: 'ChargingStationSequencesGet',
|
||||
gqlQuery: CHARGING_STATION_SEQUENCES_GET_QUERY,
|
||||
gqlVariables: {
|
||||
stationId: parsedStation.id,
|
||||
type: 'getBaseReport',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(GetBaseReportSchema),
|
||||
defaultValues: {
|
||||
requestId: 1,
|
||||
reportBase: OCPP2_0_1.ReportBaseEnumType.FullInventory,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (requestIdResponse?.data?.ChargingStationSequences?.[0]?.value) {
|
||||
form.setValue('requestId', requestIdResponse.data.ChargingStationSequences[0].value);
|
||||
}
|
||||
}, [requestIdResponse, form]);
|
||||
|
||||
const onFinish = (values: GetBaseReportFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error('Error: Cannot submit Get Base Report request because station ID is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
requestId: values.requestId,
|
||||
reportBase: values.reportBase,
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/reporting/getBaseReport?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
submitHandler={onFinish}
|
||||
loading={loading}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<FormField control={form.control} label="Request ID" name="requestId" required>
|
||||
<Input type="number" placeholder="Enter request ID" />
|
||||
</FormField>
|
||||
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
label="Report Base"
|
||||
name="reportBase"
|
||||
options={reportBaseTypes}
|
||||
placeholder="Select Report Base"
|
||||
required
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { type ChargingStationDto, OCPP2_0_1 } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { MultiSelectFormField } from '@lib/client/components/form/field';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
interface GetInstalledCertificateIdsModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
const GetInstalledCertificateIdsSchema = z.object({
|
||||
certificateType: z.array(z.enum(OCPP2_0_1.GetCertificateIdUseEnumType)).optional(),
|
||||
});
|
||||
|
||||
type GetInstalledCertificateIdsFormData = z.infer<typeof GetInstalledCertificateIdsSchema>;
|
||||
|
||||
const certificateTypeOptions = Object.values(OCPP2_0_1.GetCertificateIdUseEnumType);
|
||||
|
||||
export const GetInstalledCertificateIdsModal = ({
|
||||
station,
|
||||
}: GetInstalledCertificateIdsModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(GetInstalledCertificateIdsSchema),
|
||||
defaultValues: {
|
||||
certificateType: [],
|
||||
},
|
||||
});
|
||||
|
||||
const onFinish = async (values: GetInstalledCertificateIdsFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error(
|
||||
'Error: Cannot submit Get Installed Certificate IDs request because station ID is missing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
certificateType:
|
||||
values.certificateType && values.certificateType.length > 0
|
||||
? values.certificateType
|
||||
: undefined,
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/certificates/getInstalledCertificateIds?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormSubmit = form.handleSubmit(onFinish);
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={handleFormSubmit}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<MultiSelectFormField
|
||||
control={form.control}
|
||||
label="Certificate Types"
|
||||
name="certificateType"
|
||||
description="When certificate types are omitted, all certificate types are requested."
|
||||
options={certificateTypeOptions}
|
||||
placeholder="Select Certificate Types"
|
||||
searchPlaceholder="Search Certificate Types"
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type ChargingStationDto, OCPP2_0_1 } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { SelectFormField } from '@lib/client/components/form/field';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormField } from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
interface GetLogsModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
const GetLogsSchema = z.object({
|
||||
requestId: z.coerce.number<number>().int().positive('Request ID must be a positive number'),
|
||||
remoteLocation: z.url('Must be a valid URL').min(1, 'Remote Location is required').max(512),
|
||||
oldestTimestamp: z.string().min(1).optional(),
|
||||
latestTimestamp: z.string().min(1).optional(),
|
||||
logType: z.enum(OCPP2_0_1.LogEnumType, { message: 'Log Type is required' }),
|
||||
retries: z.coerce.number<number>().int().min(0).optional(),
|
||||
retryInterval: z.coerce.number<number>().int().min(0).optional(),
|
||||
});
|
||||
|
||||
type GetLogsFormData = z.infer<typeof GetLogsSchema>;
|
||||
|
||||
const logTypes = Object.keys(OCPP2_0_1.LogEnumType);
|
||||
|
||||
export const GetLogsModal = ({ station }: GetLogsModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const remoteLocation = 'http://localhost:4566/citrineos-s3-bucket/';
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(GetLogsSchema),
|
||||
defaultValues: {
|
||||
logType: OCPP2_0_1.LogEnumType.DiagnosticsLog,
|
||||
remoteLocation,
|
||||
},
|
||||
});
|
||||
|
||||
const onFinish = async (values: GetLogsFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error('Error: Cannot submit Get Logs request because station ID is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
log: {
|
||||
remoteLocation: values.remoteLocation,
|
||||
oldestTimestamp: values.oldestTimestamp
|
||||
? new Date(values.oldestTimestamp).toISOString()
|
||||
: undefined,
|
||||
latestTimestamp: values.latestTimestamp
|
||||
? new Date(values.latestTimestamp).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
logType: values.logType,
|
||||
requestId: values.requestId,
|
||||
...(values.retries !== undefined && { retries: values.retries }),
|
||||
...(values.retryInterval !== undefined && {
|
||||
retryInterval: values.retryInterval,
|
||||
}),
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/reporting/getLog?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
submitHandler={onFinish}
|
||||
loading={loading}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<FormField control={form.control} label="Request ID" name="requestId" required>
|
||||
<Input type="number" placeholder="Enter request ID" />
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
label="Remote Location (URL)"
|
||||
name="remoteLocation"
|
||||
required
|
||||
>
|
||||
<Input placeholder={remoteLocation} type="url" />
|
||||
</FormField>
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
label="Log Type"
|
||||
name="logType"
|
||||
options={logTypes}
|
||||
placeholder="Select Log Type"
|
||||
required
|
||||
/>
|
||||
<FormField control={form.control} label="Oldest Timestamp" name="oldestTimestamp">
|
||||
<Input type="datetime-local" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Latest Timestamp" name="latestTimestamp">
|
||||
<Input type="datetime-local" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Retries" name="retries">
|
||||
<Input type="number" placeholder="Number of retries" min="0" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Retry Interval" name="retryInterval">
|
||||
<Input type="number" placeholder="Retry interval in seconds" min="0" />
|
||||
</FormField>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type ChargingStationDto, type TransactionDto, TransactionProps } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ComboboxFormField } from '@lib/client/components/form/field';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import { GET_TRANSACTION_LIST_FOR_STATION } from '@lib/queries/transactions';
|
||||
import { ResourceType } from '@lib/utils/access.types';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useSelect } from '@refinedev/core';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface GetTransactionStatusModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
export const GetTransactionStatusSchema = z.object({
|
||||
transactionId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type GetTransactionStatusFormData = z.infer<typeof GetTransactionStatusSchema>;
|
||||
|
||||
export const GetTransactionStatusModal = ({ station }: GetTransactionStatusModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(GetTransactionStatusSchema),
|
||||
defaultValues: {
|
||||
transactionId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { options, onSearch, query } = useSelect<TransactionDto>({
|
||||
resource: ResourceType.TRANSACTIONS,
|
||||
optionLabel: TransactionProps.transactionId,
|
||||
optionValue: TransactionProps.transactionId,
|
||||
meta: {
|
||||
gqlQuery: GET_TRANSACTION_LIST_FOR_STATION,
|
||||
gqlVariables: {
|
||||
stationId: parsedStation.id,
|
||||
},
|
||||
},
|
||||
pagination: { mode: 'off' },
|
||||
});
|
||||
|
||||
const onFinish = async (values: GetTransactionStatusFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error(
|
||||
'Error: Cannot submit Get Transaction Status request because station ID is missing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: any = {};
|
||||
|
||||
if (values.transactionId) {
|
||||
data.transactionId = values.transactionId;
|
||||
}
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/transactions/getTransactionStatus?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
submitHandler={onFinish}
|
||||
loading={loading}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
name="transactionId"
|
||||
label="Transaction"
|
||||
description="Optionally select a transaction to get its status. Leave empty to get the status of all transactions."
|
||||
options={options}
|
||||
onSearch={onSearch}
|
||||
placeholder="Select Transaction"
|
||||
searchPlaceholder="Search Transactions"
|
||||
isLoading={query.isLoading}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,308 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
type ChargingStationDto,
|
||||
type ComponentDto,
|
||||
ComponentProps,
|
||||
OCPP2_0_1,
|
||||
} from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import {
|
||||
ComboboxFormField,
|
||||
FormField,
|
||||
nestedFormRowFlex,
|
||||
SelectFormField,
|
||||
} from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import { COMPONENT_LIST_QUERY } from '@lib/queries/components';
|
||||
import { VARIABLE_LIST_BY_COMPONENT_QUERY } from '@lib/queries/variables';
|
||||
import { ResourceType } from '@lib/utils/access.types';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useSelect } from '@refinedev/core';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useFieldArray } from 'react-hook-form';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { RemoveArrayItemButton } from '@lib/client/components/form/remove-array-item-button';
|
||||
import { AddArrayItemButton } from '@lib/client/components/form/add-array-item-button';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { Alert, AlertDescription } from '@lib/client/components/ui/alert';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface GetVariablesModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
const requiredIdOrName = (label: string) =>
|
||||
z.custom<number | string>(
|
||||
(val) =>
|
||||
(typeof val === 'number' && val > 0) || (typeof val === 'string' && val.trim().length > 0),
|
||||
`${label} is required`,
|
||||
);
|
||||
|
||||
const GetVariableDataSchema = z.object({
|
||||
componentId: requiredIdOrName('Component'),
|
||||
variableId: z.string().optional(),
|
||||
componentInstance: z.string().max(50).optional(),
|
||||
variableInstance: z.string().max(50).optional(),
|
||||
evseId: z.number().optional(),
|
||||
connectorId: z.number().optional(),
|
||||
attributeType: z.enum(OCPP2_0_1.AttributeEnumType).optional(),
|
||||
});
|
||||
|
||||
export const GetVariablesSchema = z.object({
|
||||
getVariableData: z
|
||||
.array(GetVariableDataSchema)
|
||||
.min(1, 'At least one variable is required')
|
||||
.refine((data) => data.every((item) => item.componentId && item.variableId), {
|
||||
message: 'Component and Variable are required for each entry',
|
||||
}),
|
||||
});
|
||||
|
||||
export type GetVariablesFormData = z.infer<typeof GetVariablesSchema>;
|
||||
|
||||
const attributeTypes = Object.keys(OCPP2_0_1.AttributeEnumType);
|
||||
|
||||
export const GetVariablesModal = ({ station }: GetVariablesModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const [variableOptionsMap, setVariableOptionsMap] = useState<
|
||||
Record<number, { label: string; value: string }[]>
|
||||
>({});
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(GetVariablesSchema),
|
||||
defaultValues: {
|
||||
getVariableData: [
|
||||
{
|
||||
componentId: 0,
|
||||
variableId: '',
|
||||
componentInstance: '',
|
||||
variableInstance: '',
|
||||
evseId: undefined,
|
||||
connectorId: undefined,
|
||||
attributeType: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'getVariableData',
|
||||
});
|
||||
|
||||
const {
|
||||
options: componentOptions,
|
||||
onSearch,
|
||||
query: componentQuery,
|
||||
} = useSelect<ComponentDto>({
|
||||
resource: ResourceType.COMPONENTS,
|
||||
optionLabel: ComponentProps.name,
|
||||
optionValue: 'id',
|
||||
meta: {
|
||||
gqlQuery: COMPONENT_LIST_QUERY,
|
||||
gqlVariables: {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
pagination: { mode: 'off' },
|
||||
});
|
||||
|
||||
const variableSelects = fields.map((field, index) => {
|
||||
const componentId = form.watch(`getVariableData.${index}.componentId`);
|
||||
|
||||
const numericComponentId = typeof componentId === 'number' && componentId > 0 ? componentId : 0;
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { options, onSearch, query } = useSelect({
|
||||
resource: ResourceType.VARIABLES,
|
||||
optionLabel: 'name',
|
||||
optionValue: 'name',
|
||||
meta: {
|
||||
gqlQuery: VARIABLE_LIST_BY_COMPONENT_QUERY,
|
||||
gqlVariables: numericComponentId
|
||||
? { componentId: numericComponentId, offset: 0, limit: 100, mutability: '' }
|
||||
: undefined,
|
||||
},
|
||||
pagination: { mode: 'off' },
|
||||
queryOptions: { enabled: numericComponentId > 0 },
|
||||
});
|
||||
|
||||
if (numericComponentId > 0 && options.length > 0 && variableOptionsMap[index] !== options) {
|
||||
setVariableOptionsMap((prev) => ({ ...prev, [index]: options }));
|
||||
}
|
||||
|
||||
return { options, onSearch, isLoading: query.isLoading };
|
||||
});
|
||||
|
||||
const onFinish = async (values: GetVariablesFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error('Error: Cannot submit Get Variables request because station ID is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const getVariableData = values.getVariableData.map((item) => {
|
||||
const componentName =
|
||||
typeof item.componentId === 'string'
|
||||
? item.componentId
|
||||
: (componentOptions.find((c) => c.value === item.componentId) as any)?.label || '';
|
||||
|
||||
const data: any = {
|
||||
component: {
|
||||
name: componentName,
|
||||
...(item.componentInstance && { instance: item.componentInstance }),
|
||||
},
|
||||
variable: {
|
||||
name: item.variableId || componentName,
|
||||
...(item.variableInstance && { instance: item.variableInstance }),
|
||||
},
|
||||
...(item.attributeType && { attributeType: item.attributeType }),
|
||||
};
|
||||
|
||||
if (item.evseId !== undefined) {
|
||||
data.component.evse = {
|
||||
id: item.evseId,
|
||||
...(item.connectorId !== undefined && {
|
||||
connectorId: item.connectorId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/monitoring/getVariables?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data: { getVariableData },
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
// TODO do we also include Variable, EVSE, and Connector selectors?
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={onFinish}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
submitButtonlabel="Get Variables"
|
||||
hideCancel
|
||||
>
|
||||
<Alert className="mb-4">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Send a GetBaseReport to this Charging Station to populate Components and Variables.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex items-start">
|
||||
<AddArrayItemButton
|
||||
onAppendAction={() =>
|
||||
append({
|
||||
componentId: 0,
|
||||
variableId: '',
|
||||
componentInstance: '',
|
||||
variableInstance: '',
|
||||
evseId: undefined,
|
||||
connectorId: undefined,
|
||||
attributeType: undefined,
|
||||
})
|
||||
}
|
||||
itemLabel="Variable"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
{fields.map((field, index) => {
|
||||
const componentId = form.watch(`getVariableData.${index}.componentId`);
|
||||
const {
|
||||
options: variableOptions,
|
||||
onSearch: variableOnSearch,
|
||||
isLoading: variableLoading,
|
||||
} = variableSelects[index] || {
|
||||
options: [],
|
||||
onSearch: () => {},
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={field.id} className={nestedFormRowFlex}>
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
label={`Component #${index + 1}`}
|
||||
name={`getVariableData.${index}.componentId`}
|
||||
options={componentOptions}
|
||||
onSearch={onSearch}
|
||||
placeholder="Select Component"
|
||||
searchPlaceholder="Search Components"
|
||||
isLoading={componentQuery.isLoading}
|
||||
required
|
||||
allowManualEntry
|
||||
/>
|
||||
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
label={`Variable #${index + 1}`}
|
||||
name={`getVariableData.${index}.variableId`}
|
||||
options={variableOptions}
|
||||
onSearch={variableOnSearch}
|
||||
placeholder="Select Variable"
|
||||
searchPlaceholder="Search Variables"
|
||||
isLoading={variableLoading}
|
||||
required
|
||||
disabled={!componentId || componentId === 0}
|
||||
allowManualEntry
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
label={`Component Instance #${index + 1}`}
|
||||
name={`getVariableData.${index}.componentInstance`}
|
||||
>
|
||||
<Input placeholder="Instance" />
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
label={`Variable Instance #${index + 1}`}
|
||||
name={`getVariableData.${index}.variableInstance`}
|
||||
>
|
||||
<Input placeholder="Instance" />
|
||||
</FormField>
|
||||
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
label={`Attribute Type #${index + 1}`}
|
||||
name={`getVariableData.${index}.attributeType`}
|
||||
options={attributeTypes}
|
||||
placeholder="Select Attribute Type"
|
||||
/>
|
||||
|
||||
<RemoveArrayItemButton onRemoveAction={() => remove(index)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type ChargingStationDto, OCPP2_0_1 } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormField, SelectFormField } from '@lib/client/components/form/field';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { formatPem, triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { toast } from 'sonner';
|
||||
import z from 'zod';
|
||||
import { Textarea } from '@lib/client/components/ui/textarea';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
interface InstallCertificateModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
const InstallCertificateSchema = z.object({
|
||||
certificate: z
|
||||
.string()
|
||||
.min(1, 'Certificate is required')
|
||||
.max(5500, 'Certificate must be less than 5500 characters'),
|
||||
certificateType: z.enum(OCPP2_0_1.InstallCertificateUseEnumType, {
|
||||
message: 'Certificate Type is required',
|
||||
}),
|
||||
});
|
||||
|
||||
type InstallCertificateFormData = z.infer<typeof InstallCertificateSchema>;
|
||||
|
||||
const installCertificateTypes = Object.keys(OCPP2_0_1.InstallCertificateUseEnumType);
|
||||
|
||||
export const InstallCertificateModal = ({ station }: InstallCertificateModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(InstallCertificateSchema),
|
||||
defaultValues: {
|
||||
certificate: '',
|
||||
certificateType: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: InstallCertificateFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error(
|
||||
'Error: Cannot submit Install Certificate request because station ID is missing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pemString = formatPem(values.certificate);
|
||||
if (pemString == null) {
|
||||
toast.error('Incorrectly formatted PEM certificate');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
certificate: pemString,
|
||||
certificateType: values.certificateType,
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/certificates/installCertificate?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={handleSubmit}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
label="Certificate Type"
|
||||
name="certificateType"
|
||||
options={installCertificateTypes}
|
||||
placeholder="Select Certificate Type"
|
||||
required
|
||||
/>
|
||||
|
||||
<FormField control={form.control} label="Certificate (PEM)" name="certificate" required>
|
||||
<Textarea />
|
||||
</FormField>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,437 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type ChargingStationDto, OCPP2_0_1, type ServerNetworkProfileDto } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import {
|
||||
CheckboxFormField,
|
||||
ComboboxFormField,
|
||||
formCheckboxStyle,
|
||||
FormField,
|
||||
formLabelStyle,
|
||||
formLabelWrapperStyle,
|
||||
SelectFormField,
|
||||
} from '@lib/client/components/form/field';
|
||||
import { Checkbox } from '@lib/client/components/ui/checkbox';
|
||||
import { Field, FieldLabel } from '@lib/client/components/ui/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { Textarea } from '@lib/client/components/ui/textarea';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import { SERVER_NETWORK_PROFILE_LIST_QUERY } from '@lib/queries/server.network.profiles';
|
||||
import { ResourceType } from '@lib/utils/access.types';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useSelect } from '@refinedev/core';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
|
||||
export interface SetNetworkProfileModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
// APN Schema
|
||||
const ApnSchema = z.object({
|
||||
apn: z.string().min(1, 'APN is required').max(512),
|
||||
apnUserName: z.string().max(20).optional(),
|
||||
apnPassword: z.string().max(20).optional(),
|
||||
simPin: z.coerce.number<number>().int().optional(),
|
||||
preferredNetwork: z.string().max(6).optional(),
|
||||
useOnlyPreferredNetwork: z.boolean().optional(),
|
||||
apnAuthentication: z.enum(OCPP2_0_1.APNAuthenticationEnumType, {
|
||||
message: 'APN Authentication is required',
|
||||
}),
|
||||
});
|
||||
|
||||
// VPN Schema
|
||||
const VpnSchema = z.object({
|
||||
server: z.string().min(1, 'Server is required').max(512),
|
||||
user: z.string().min(1, 'User is required').max(20),
|
||||
group: z.string().max(20).optional(),
|
||||
password: z.string().min(1, 'Password is required').max(20),
|
||||
key: z.string().min(1, 'Key is required').max(255),
|
||||
type: z.enum(OCPP2_0_1.VPNEnumType, {
|
||||
message: 'VPN Type is required',
|
||||
}),
|
||||
});
|
||||
|
||||
// Network Connection Profile Schema
|
||||
const NetworkConnectionProfileSchema = z.object({
|
||||
ocppVersion: z.enum(OCPP2_0_1.OCPPVersionEnumType, {
|
||||
message: 'OCPP Version is required',
|
||||
}),
|
||||
ocppTransport: z.enum(OCPP2_0_1.OCPPTransportEnumType, {
|
||||
message: 'OCPP Transport is required',
|
||||
}),
|
||||
ocppCsmsUrl: z.string().url('Must be a valid URL').min(1, 'CSMS URL is required').max(512),
|
||||
messageTimeout: z.coerce.number<number>().int().min(0, 'Message timeout must be positive'),
|
||||
securityProfile: z.coerce.number<number>().int().min(0, 'Security profile must be positive'),
|
||||
ocppInterface: z.enum(OCPP2_0_1.OCPPInterfaceEnumType, {
|
||||
message: 'OCPP Interface is required',
|
||||
}),
|
||||
apn: ApnSchema.optional(),
|
||||
vpn: VpnSchema.optional(),
|
||||
});
|
||||
|
||||
const SetNetworkProfileSchema = z.object({
|
||||
websocketServerConfigId: z.string().optional(),
|
||||
configurationSlot: z.coerce
|
||||
.number<number>()
|
||||
.int()
|
||||
.min(0, 'Configuration slot must be non-negative'),
|
||||
connectionData: NetworkConnectionProfileSchema,
|
||||
includeApn: z.boolean(),
|
||||
includeVpn: z.boolean(),
|
||||
});
|
||||
|
||||
type SetNetworkProfileFormData = z.infer<typeof SetNetworkProfileSchema>;
|
||||
|
||||
const fieldGrid = 'grid grid-cols-3 sm:grid-cols-2 xl:grid-cols-4 gap-6';
|
||||
|
||||
const ocppVersions = Object.keys(OCPP2_0_1.OCPPVersionEnumType);
|
||||
const ocppTransports = Object.keys(OCPP2_0_1.OCPPTransportEnumType);
|
||||
const ocppInterfaces = Object.keys(OCPP2_0_1.OCPPInterfaceEnumType);
|
||||
const apnAuthenticationTypes = Object.keys(OCPP2_0_1.APNAuthenticationEnumType);
|
||||
const vpnTypes = Object.keys(OCPP2_0_1.VPNEnumType);
|
||||
|
||||
export const SetNetworkProfileModal = ({ station }: SetNetworkProfileModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(SetNetworkProfileSchema),
|
||||
defaultValues: {
|
||||
websocketServerConfigId: undefined,
|
||||
configurationSlot: 1,
|
||||
connectionData: {
|
||||
ocppVersion: OCPP2_0_1.OCPPVersionEnumType.OCPP20,
|
||||
ocppTransport: OCPP2_0_1.OCPPTransportEnumType.JSON,
|
||||
ocppCsmsUrl: '',
|
||||
messageTimeout: 30,
|
||||
securityProfile: 0,
|
||||
ocppInterface: OCPP2_0_1.OCPPInterfaceEnumType.Wired0,
|
||||
apn: undefined,
|
||||
vpn: undefined,
|
||||
},
|
||||
includeApn: false,
|
||||
includeVpn: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
options: serverNetworkProfileOptions,
|
||||
onSearch: serverNetworkProfileOnSearch,
|
||||
query: serverNetworkProfileQuery,
|
||||
} = useSelect<ServerNetworkProfileDto>({
|
||||
resource: ResourceType.SERVER_NETWORK_PROFILES,
|
||||
optionLabel: (item: any) => `${item.host}:${item.port}`,
|
||||
optionValue: 'id',
|
||||
meta: {
|
||||
gqlQuery: SERVER_NETWORK_PROFILE_LIST_QUERY,
|
||||
gqlVariables: {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
pagination: { mode: 'off' },
|
||||
});
|
||||
|
||||
const onFinish = (values: SetNetworkProfileFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error(
|
||||
'Error: Cannot submit Set Network Profile request because station ID is missing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove includeApn and includeVpn flags, and clean up connectionData
|
||||
const connectionData: any = {
|
||||
ocppVersion: values.connectionData.ocppVersion,
|
||||
ocppTransport: values.connectionData.ocppTransport,
|
||||
ocppCsmsUrl: values.connectionData.ocppCsmsUrl,
|
||||
messageTimeout: values.connectionData.messageTimeout,
|
||||
securityProfile: values.connectionData.securityProfile,
|
||||
ocppInterface: values.connectionData.ocppInterface,
|
||||
};
|
||||
|
||||
if (values.includeApn && values.connectionData.apn) {
|
||||
connectionData.apn = values.connectionData.apn;
|
||||
}
|
||||
|
||||
if (values.includeVpn && values.connectionData.vpn) {
|
||||
connectionData.vpn = values.connectionData.vpn;
|
||||
}
|
||||
|
||||
const data = {
|
||||
configurationSlot: values.configurationSlot,
|
||||
connectionData,
|
||||
};
|
||||
|
||||
let url = `/configuration/setNetworkProfile?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`;
|
||||
if (values.websocketServerConfigId) {
|
||||
url = `${url}&websocketServerConfigId=${values.websocketServerConfigId}`;
|
||||
}
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormSubmit = () => onFinish(form.getValues());
|
||||
|
||||
const includeApn = form.watch('includeApn');
|
||||
const includeVpn = form.watch('includeVpn');
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
submitHandler={handleFormSubmit}
|
||||
loading={loading}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
submitButtonLabel="Set Network Profile"
|
||||
hideCancel
|
||||
>
|
||||
<div className={fieldGrid}>
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
label="Websocket Server Config"
|
||||
name="websocketServerConfigId"
|
||||
options={serverNetworkProfileOptions}
|
||||
onSearch={serverNetworkProfileOnSearch}
|
||||
placeholder="Search Server Network Profiles"
|
||||
isLoading={serverNetworkProfileQuery.isLoading}
|
||||
/>
|
||||
|
||||
<FormField control={form.control} label="Configuration Slot" name="configurationSlot">
|
||||
<Input type="number" min="0" />
|
||||
</FormField>
|
||||
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
label="OCPP Version"
|
||||
name="connectionData.ocppVersion"
|
||||
options={ocppVersions}
|
||||
placeholder="Select OCPP Version"
|
||||
required
|
||||
/>
|
||||
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
label="OCPP Transport"
|
||||
name="connectionData.ocppTransport"
|
||||
options={ocppTransports}
|
||||
placeholder="Select OCPP Transport"
|
||||
required
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
label="OCPP CSMS URL"
|
||||
name="connectionData.ocppCsmsUrl"
|
||||
required
|
||||
>
|
||||
<Input placeholder="wss://example.com/ocpp" type="url" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
label="Message Timeout (seconds)"
|
||||
name="connectionData.messageTimeout"
|
||||
>
|
||||
<Input type="number" min="0" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
label="Security Profile"
|
||||
name="connectionData.securityProfile"
|
||||
>
|
||||
<Input type="number" min={0} />
|
||||
</FormField>
|
||||
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
label="OCPP Interface"
|
||||
name="connectionData.ocppInterface"
|
||||
options={ocppInterfaces}
|
||||
placeholder="Select OCPP Interface"
|
||||
required
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
label="Security Profile"
|
||||
name="connectionData.securityProfile"
|
||||
>
|
||||
<Input type="number" min={0} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className={fieldGrid}>
|
||||
{/* APN Section */}
|
||||
{/* Checkbox to show/hide APN section */}
|
||||
<Field>
|
||||
<FieldLabel className={formLabelWrapperStyle}>
|
||||
<span className={formLabelStyle}>Include APN Configuration</span>
|
||||
</FieldLabel>
|
||||
<Checkbox
|
||||
className={formCheckboxStyle}
|
||||
checked={includeApn}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue('includeApn', checked as boolean);
|
||||
if (!checked) {
|
||||
form.setValue('connectionData.apn', undefined);
|
||||
} else {
|
||||
form.setValue('connectionData.apn', {
|
||||
apn: '',
|
||||
apnAuthentication: OCPP2_0_1.APNAuthenticationEnumType.CHAP,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{includeApn && (
|
||||
<>
|
||||
<FormField control={form.control} label="APN" name="connectionData.apn.apn" required>
|
||||
<Input placeholder="APN name" />
|
||||
</FormField>
|
||||
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
label="APN Authentication"
|
||||
name="connectionData.apn.apnAuthentication"
|
||||
options={apnAuthenticationTypes}
|
||||
placeholder="Select Authentication Type"
|
||||
required
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
label="APN Username"
|
||||
name="connectionData.apn.apnUserName"
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
label="APN Password"
|
||||
name="connectionData.apn.apnPassword"
|
||||
>
|
||||
<Input type="password" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="SIM PIN" name="connectionData.apn.simPin">
|
||||
<Input type="number" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
label="Preferred Network"
|
||||
name="connectionData.apn.preferredNetwork"
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
|
||||
<CheckboxFormField
|
||||
control={form.control}
|
||||
label="Use Only Preferred Network"
|
||||
name="connectionData.apn.useOnlyPreferredNetwork"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={fieldGrid}>
|
||||
{/* VPN Section */}
|
||||
{/* Checkbox to show/hide VPN section */}
|
||||
<Field>
|
||||
<FieldLabel className={formLabelWrapperStyle}>
|
||||
<span className={formLabelStyle}>Include VPN Configuration</span>
|
||||
</FieldLabel>
|
||||
<Checkbox
|
||||
className={formCheckboxStyle}
|
||||
checked={includeVpn}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue('includeVpn', checked as boolean);
|
||||
if (!checked) {
|
||||
form.setValue('connectionData.vpn', undefined);
|
||||
} else {
|
||||
form.setValue('connectionData.vpn', {
|
||||
server: '',
|
||||
user: '',
|
||||
password: '',
|
||||
key: '',
|
||||
type: OCPP2_0_1.VPNEnumType.IKEv2,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{includeVpn && (
|
||||
<>
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
label="VPN Type"
|
||||
name="connectionData.vpn.type"
|
||||
options={vpnTypes}
|
||||
placeholder="Select VPN Type"
|
||||
required
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
label="Server"
|
||||
name="connectionData.vpn.server"
|
||||
required
|
||||
>
|
||||
<Input placeholder="VPN server" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="User" name="connectionData.vpn.user" required>
|
||||
<Input placeholder="VPN username" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Group" name="connectionData.vpn.group">
|
||||
<Input />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
label="Password"
|
||||
name="connectionData.vpn.password"
|
||||
required
|
||||
>
|
||||
<Input type="password" placeholder="VPN password" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Key" name="connectionData.vpn.key" required>
|
||||
<Textarea placeholder="VPN key" rows={3} />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,282 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ChargingStationDto,
|
||||
type ComponentDto,
|
||||
ComponentProps,
|
||||
OCPP2_0_1,
|
||||
} from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
ComboboxFormField,
|
||||
FormField,
|
||||
nestedFormRowFlex,
|
||||
SelectFormField,
|
||||
} from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import { COMPONENT_LIST_QUERY } from '@lib/queries/components';
|
||||
import { VARIABLE_LIST_BY_COMPONENT_QUERY } from '@lib/queries/variables';
|
||||
import { ResourceType } from '@lib/utils/access.types';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useSelect } from '@refinedev/core';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useFieldArray } from 'react-hook-form';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { AddArrayItemButton } from '@lib/client/components/form/add-array-item-button';
|
||||
import { RemoveArrayItemButton } from '@lib/client/components/form/remove-array-item-button';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { Alert, AlertDescription } from '@lib/client/components/ui/alert';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
interface SetVariablesModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
const requiredIdOrName = (label: string) =>
|
||||
z.custom<number | string>(
|
||||
(val) =>
|
||||
(typeof val === 'number' && val > 0) || (typeof val === 'string' && val.trim().length > 0),
|
||||
`${label} is required`,
|
||||
);
|
||||
|
||||
const SetVariableDataSchema = z.object({
|
||||
componentId: requiredIdOrName('Component'),
|
||||
variableId: requiredIdOrName('Variable'),
|
||||
value: z.string().min(1, 'Value is required'),
|
||||
attributeType: z.enum(OCPP2_0_1.AttributeEnumType).optional(),
|
||||
});
|
||||
|
||||
const SetVariablesSchema = z.object({
|
||||
setVariableData: z
|
||||
.array(SetVariableDataSchema)
|
||||
.min(1, 'At least one variable is required')
|
||||
.refine((data) => data.every((item) => item.componentId && item.variableId && item.value), {
|
||||
message: 'Component, Variable, and Value are required for each entry',
|
||||
}),
|
||||
});
|
||||
|
||||
type SetVariablesFormData = z.infer<typeof SetVariablesSchema>;
|
||||
|
||||
const attributeTypes = Object.keys(OCPP2_0_1.AttributeEnumType);
|
||||
|
||||
export const SetVariablesModal = ({ station }: SetVariablesModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [variableOptionsMap, setVariableOptionsMap] = useState<
|
||||
Record<number, { label: string; value: number }[]>
|
||||
>({});
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(SetVariablesSchema),
|
||||
defaultValues: {
|
||||
setVariableData: [
|
||||
{
|
||||
componentId: 0,
|
||||
variableId: 0,
|
||||
value: '',
|
||||
attributeType: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'setVariableData',
|
||||
});
|
||||
|
||||
const {
|
||||
options: componentOptions,
|
||||
onSearch: componentOnSearch,
|
||||
query: componentQuery,
|
||||
} = useSelect<ComponentDto>({
|
||||
resource: ResourceType.COMPONENTS,
|
||||
optionLabel: ComponentProps.name,
|
||||
optionValue: 'id',
|
||||
meta: {
|
||||
gqlQuery: COMPONENT_LIST_QUERY,
|
||||
gqlVariables: {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
pagination: { mode: 'off' },
|
||||
});
|
||||
|
||||
const variableSelects = fields.map((field, index) => {
|
||||
const componentId = form.watch(`setVariableData.${index}.componentId`);
|
||||
const numericComponentId = typeof componentId === 'number' && componentId > 0 ? componentId : 0;
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { options, onSearch, query } = useSelect({
|
||||
resource: ResourceType.VARIABLES,
|
||||
optionLabel: 'name',
|
||||
optionValue: 'id',
|
||||
meta: {
|
||||
gqlQuery: VARIABLE_LIST_BY_COMPONENT_QUERY,
|
||||
gqlVariables: numericComponentId
|
||||
? { componentId: numericComponentId, offset: 0, limit: 100, mutability: 'ReadOnly' }
|
||||
: undefined,
|
||||
},
|
||||
pagination: { mode: 'off' },
|
||||
queryOptions: { enabled: numericComponentId > 0 },
|
||||
});
|
||||
|
||||
if (numericComponentId > 0 && options.length > 0 && variableOptionsMap[index] !== options) {
|
||||
setVariableOptionsMap((prev) => ({ ...prev, [index]: options }));
|
||||
}
|
||||
|
||||
return { options, onSearch, isLoading: query.isLoading };
|
||||
});
|
||||
|
||||
const onFinish = async (values: SetVariablesFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error('Error: Cannot submit Set Variables request because station ID is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const setVariableData = values.setVariableData.map((item, index) => {
|
||||
const componentName =
|
||||
typeof item.componentId === 'string'
|
||||
? item.componentId
|
||||
: (componentOptions.find((c) => c.value === item.componentId) as any)?.label || '';
|
||||
|
||||
const variableName =
|
||||
typeof item.variableId === 'string'
|
||||
? item.variableId
|
||||
: variableOptionsMap[index]?.find((v) => v.value === item.variableId)?.label || '';
|
||||
|
||||
return {
|
||||
component: {
|
||||
name: componentName,
|
||||
},
|
||||
variable: {
|
||||
name: variableName,
|
||||
},
|
||||
attributeValue: item.value,
|
||||
...(item.attributeType && { attributeType: item.attributeType }),
|
||||
};
|
||||
});
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/monitoring/setVariables?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data: { setVariableData },
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={onFinish}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
submitButtonLabel="Set Variables"
|
||||
hideCancel
|
||||
>
|
||||
<Alert className="mb-4">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Send a GetBaseReport to this Charging Station to populate Components and Variables.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex items-start">
|
||||
<AddArrayItemButton
|
||||
onAppendAction={() =>
|
||||
append({
|
||||
componentId: 0,
|
||||
variableId: 0,
|
||||
value: '',
|
||||
attributeType: undefined,
|
||||
})
|
||||
}
|
||||
itemLabel="Variable"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
{fields.map((field, index) => {
|
||||
const componentId = form.watch(`setVariableData.${index}.componentId`);
|
||||
const {
|
||||
options: variableOptions,
|
||||
onSearch: variableOnSearch,
|
||||
isLoading: variableLoading,
|
||||
} = variableSelects[index] || {
|
||||
options: [],
|
||||
onSearch: () => {},
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={field.id} className={nestedFormRowFlex}>
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
label={`Component #${index + 1}`}
|
||||
name={`setVariableData.${index}.componentId`}
|
||||
options={componentOptions}
|
||||
onSearch={componentOnSearch}
|
||||
placeholder="Select Component"
|
||||
searchPlaceholder="Search Component"
|
||||
isLoading={componentQuery.isLoading}
|
||||
allowManualEntry
|
||||
/>
|
||||
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
label={`Variable #${index + 1}`}
|
||||
name={`setVariableData.${index}.variableId`}
|
||||
options={variableOptions}
|
||||
onSearch={variableOnSearch}
|
||||
placeholder="Select Variable"
|
||||
searchPlaceholder="Search Variables"
|
||||
isLoading={variableLoading}
|
||||
required
|
||||
disabled={!componentId || componentId === 0}
|
||||
allowManualEntry
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
label={`Value #${index + 1}`}
|
||||
name={`setVariableData.${index}.value`}
|
||||
>
|
||||
<Input placeholder="Value To Set" />
|
||||
</FormField>
|
||||
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
label={`Attribute Type #${index + 1}`}
|
||||
name={`setVariableData.${index}.attributeType`}
|
||||
options={attributeTypes}
|
||||
placeholder="Select Attribute Type"
|
||||
/>
|
||||
|
||||
<RemoveArrayItemButton onRemoveAction={() => remove(index)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { ChargingStationDto } from '@citrineos/base';
|
||||
import { OCPP2_0_1 } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { ComboboxFormField } from '@lib/client/components/form/field';
|
||||
import { ConnectorSelector } from '@lib/client/components/modals/shared/connector-selector/connector.selector';
|
||||
import { EvseSelector } from '@lib/client/components/modals/shared/evse-selector/evse.selector';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface TriggerMessageModalProps {
|
||||
station: ChargingStationDto;
|
||||
}
|
||||
|
||||
const TriggerMessageSchema = z.object({
|
||||
requestedMessage: z.enum(OCPP2_0_1.MessageTriggerEnumType, {
|
||||
message: 'Please select a message type',
|
||||
}),
|
||||
evse: z.string().optional(), // { id, evseTypeId }
|
||||
connectorId: z.number().optional(),
|
||||
});
|
||||
|
||||
type TriggerMessageFormData = z.infer<typeof TriggerMessageSchema>;
|
||||
|
||||
const messageTriggers = Object.keys(OCPP2_0_1.MessageTriggerEnumType);
|
||||
|
||||
export const TriggerMessageModal = ({ station }: TriggerMessageModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(TriggerMessageSchema),
|
||||
defaultValues: {
|
||||
requestedMessage: undefined,
|
||||
evse: undefined,
|
||||
connectorId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: TriggerMessageFormData) => {
|
||||
const data: any = {
|
||||
requestedMessage: values.requestedMessage,
|
||||
};
|
||||
|
||||
if (values.evse !== undefined) {
|
||||
const parsedEvse = JSON.parse(values.evse);
|
||||
|
||||
data.evse = {
|
||||
id: parsedEvse.evseTypeId,
|
||||
...(values.connectorId !== undefined ? { connectorId: values.connectorId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/configuration/triggerMessage?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
const handleEvseSelection = (value: string) => {
|
||||
form.setValue('evse', value);
|
||||
form.setValue('connectorId', 1);
|
||||
};
|
||||
|
||||
const handleConnectorSelection = (value: number) => {
|
||||
form.setValue('connectorId', value);
|
||||
};
|
||||
|
||||
const selectedEvseId = form.watch('evse');
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={handleSubmit}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
name="requestedMessage"
|
||||
label="Requested Message"
|
||||
options={messageTriggers.map((mt: string) => ({
|
||||
label: mt,
|
||||
value: mt,
|
||||
}))}
|
||||
placeholder="Select Message Type"
|
||||
searchPlaceholder="Search Message Types"
|
||||
required
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="evse"
|
||||
render={({ field }) => (
|
||||
<EvseSelector
|
||||
station={parsedStation}
|
||||
value={field.value ?? undefined}
|
||||
onSelect={handleEvseSelection}
|
||||
isOptional
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="connectorId"
|
||||
render={({ field }) => (
|
||||
<ConnectorSelector
|
||||
station={parsedStation}
|
||||
evseId={selectedEvseId ? JSON.parse(selectedEvseId).id : undefined}
|
||||
value={field.value ?? undefined}
|
||||
onSelect={handleConnectorSelection}
|
||||
isOptional
|
||||
requiresEvseId
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type ChargingStationDto } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ConnectorSelector } from '@lib/client/components/modals/shared/connector-selector/connector.selector';
|
||||
import { EvseSelector } from '@lib/client/components/modals/shared/evse-selector/evse.selector';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
interface UnlockConnectorModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
const UnlockConnectorSchema = z.object({
|
||||
evse: z.string({
|
||||
message: 'EVSE is required',
|
||||
}), // { id, evseTypeId }
|
||||
connectorId: z.number({
|
||||
message: 'Connector is required',
|
||||
}),
|
||||
});
|
||||
|
||||
type UnlockConnectorFormData = z.infer<typeof UnlockConnectorSchema>;
|
||||
|
||||
export const UnlockConnectorModal = ({ station }: UnlockConnectorModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(UnlockConnectorSchema),
|
||||
defaultValues: {
|
||||
evse: undefined,
|
||||
connectorId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const onFinish = (values: UnlockConnectorFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error('Error: Cannot submit Unlock Connector request because station ID is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedEvse = JSON.parse(values.evse);
|
||||
|
||||
const data = {
|
||||
evseId: parsedEvse.evseTypeId,
|
||||
connectorId: values.connectorId,
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/evdriver/unlockConnector?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
const handleEvseSelection = (value: string) => {
|
||||
form.setValue('evse', value);
|
||||
form.setValue('connectorId', 1);
|
||||
};
|
||||
|
||||
const handleConnectorSelection = (value: number) => {
|
||||
form.setValue('connectorId', value);
|
||||
};
|
||||
|
||||
const selectedEvseId = form.watch('evse');
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={onFinish}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
submitButtonLabel="Unlock Connector"
|
||||
hideCancel
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="evse"
|
||||
render={({ field }) => (
|
||||
<EvseSelector
|
||||
station={parsedStation}
|
||||
value={field.value ?? undefined}
|
||||
onSelect={handleEvseSelection}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="connectorId"
|
||||
render={({ field }) => (
|
||||
<ConnectorSelector
|
||||
station={parsedStation}
|
||||
evseId={selectedEvseId ? JSON.parse(selectedEvseId).id : undefined}
|
||||
value={field.value ?? undefined}
|
||||
onSelect={handleConnectorSelection}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type ChargingStationDto } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { CheckboxFormField, FormField } from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
|
||||
interface UpdateAuthPasswordModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
const UpdateAuthPasswordSchema = z.object({
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
setOnCharger: z.boolean(),
|
||||
});
|
||||
|
||||
type UpdateAuthPasswordFormData = z.infer<typeof UpdateAuthPasswordSchema>;
|
||||
|
||||
export const UpdateAuthPasswordModal = ({ station }: UpdateAuthPasswordModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(UpdateAuthPasswordSchema),
|
||||
defaultValues: {
|
||||
password: '',
|
||||
setOnCharger: false,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: UpdateAuthPasswordFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error(
|
||||
'Error: Cannot submit Update Auth Password request because station ID is missing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
password: values.password,
|
||||
setOnCharger: values.setOnCharger,
|
||||
ocppConnectionName: parsedStation.ocppConnectionName,
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation>({
|
||||
url: `/configuration/password`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: null,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={handleSubmit}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
submitButtonlabel="Update Password"
|
||||
hideCancel
|
||||
>
|
||||
<FormField control={form.control} label="Password" name="password" required>
|
||||
<Input type="password" placeholder="Enter new password" />
|
||||
</FormField>
|
||||
|
||||
<CheckboxFormField
|
||||
control={form.control}
|
||||
label="Set On Charger"
|
||||
name="setOnCharger"
|
||||
required
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import type { ChargingStationDto } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormField } from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import { CHARGING_STATION_SEQUENCES_GET_QUERY } from '@lib/queries/charging.station.sequences';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { formatPem, triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useApiUrl, useCustom } from '@refinedev/core';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { toast } from 'sonner';
|
||||
import z from 'zod';
|
||||
import { Textarea } from '@lib/client/components/ui/textarea';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface UpdateFirmwareModalProps {
|
||||
station: ChargingStationDto;
|
||||
}
|
||||
|
||||
const UpdateFirmwareSchema = z.object({
|
||||
requestId: z.coerce.number<number>().int().min(0),
|
||||
retries: z.coerce.number<number>().int().min(0).optional(),
|
||||
retryInterval: z.coerce.number<number>().int().min(0).optional(),
|
||||
location: z.string().url('Must be a valid URL').min(1).max(512),
|
||||
retrieveDateTime: z.string().min(1, 'Retrieve date is required'),
|
||||
installDateTime: z.string().optional(),
|
||||
signingCertificate: z.string().max(5500).optional(),
|
||||
signature: z.string().max(800).optional(),
|
||||
});
|
||||
|
||||
type UpdateFirmwareFormData = z.infer<typeof UpdateFirmwareSchema>;
|
||||
|
||||
export const UpdateFirmwareModal = ({ station }: UpdateFirmwareModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const apiUrl = useApiUrl();
|
||||
const {
|
||||
query: { data: requestIdResponse, isLoading: isRequestIdLoading },
|
||||
} = useCustom({
|
||||
url: `${apiUrl}`,
|
||||
method: 'post',
|
||||
config: { headers: { 'Content-Type': 'application/json' } },
|
||||
meta: {
|
||||
operation: 'ChargingStationSequencesGet',
|
||||
gqlQuery: CHARGING_STATION_SEQUENCES_GET_QUERY,
|
||||
gqlVariables: { stationId: station.id, type: 'updateFirmware' },
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(UpdateFirmwareSchema),
|
||||
defaultValues: {
|
||||
requestId: 0,
|
||||
retries: undefined,
|
||||
retryInterval: undefined,
|
||||
location: '',
|
||||
retrieveDateTime: '',
|
||||
installDateTime: '',
|
||||
signingCertificate: '',
|
||||
signature: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (requestIdResponse?.data?.ChargingStationSequences?.[0]?.value) {
|
||||
form.setValue('requestId', requestIdResponse.data.ChargingStationSequences[0].value);
|
||||
}
|
||||
}, [requestIdResponse, form]);
|
||||
|
||||
const handleSubmit = (values: UpdateFirmwareFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error('Error: Cannot submit Update Firmware request because station ID is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
let signingCertificate: string | undefined;
|
||||
if (values.signingCertificate && values.signingCertificate.trim()) {
|
||||
const pemString = formatPem(values.signingCertificate);
|
||||
if (!pemString) {
|
||||
toast.error('Incorrectly formatted PEM certificate');
|
||||
return;
|
||||
}
|
||||
signingCertificate = pemString;
|
||||
}
|
||||
|
||||
const data = {
|
||||
requestId: values.requestId,
|
||||
...(values.retries !== undefined && { retries: values.retries }),
|
||||
...(values.retryInterval !== undefined && {
|
||||
retryInterval: values.retryInterval,
|
||||
}),
|
||||
firmware: {
|
||||
location: values.location,
|
||||
retrieveDateTime: new Date(values.retrieveDateTime).toISOString(),
|
||||
...(values.installDateTime && {
|
||||
installDateTime: new Date(values.installDateTime).toISOString(),
|
||||
}),
|
||||
...(signingCertificate && { signingCertificate }),
|
||||
...(values.signature && { signature: values.signature }),
|
||||
},
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/configuration/updateFirmware?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: parsedStation.protocol,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading || isRequestIdLoading}
|
||||
submitHandler={handleSubmit}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
submitButtonLabel="Update Firmware"
|
||||
hideCancel
|
||||
>
|
||||
<FormField control={form.control} label="Request ID" name="requestId" required>
|
||||
<Input type="number" placeholder="Request ID" min="0" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Location (URL)" name="location" required>
|
||||
<Input placeholder="https://example.com/firmware.bin" type="url" required />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Retrieve Date/Time" name="retrieveDateTime" required>
|
||||
<Input type="datetime-local" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Install Date/Time" name="installDateTime">
|
||||
<Input type="datetime-local" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Retries" name="retries">
|
||||
<Input type="number" placeholder="Number of retries" min="0" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Retry Interval" name="retryInterval">
|
||||
<Input type="number" placeholder="Retry interval in seconds" min="0" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Signing Certificate (PEM)" name="signingCertificate">
|
||||
<Textarea placeholder="Paste PEM-formatted certificate here" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Signature" name="signature">
|
||||
<Input placeholder="Firmware signature" />
|
||||
</FormField>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type ChargingStationDto, HttpMethod } from '@citrineos/base';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Button } from '@lib/client/components/ui/button';
|
||||
import { useTranslate } from '@refinedev/core';
|
||||
|
||||
export interface ForceDisconnectModalProps {
|
||||
station: ChargingStationDto;
|
||||
}
|
||||
|
||||
export const ForceDisconnectModal = ({ station }: ForceDisconnectModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const translate = useTranslate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const onOkay = async () => {
|
||||
await triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/ocpprouter/connection?ocppConnectionName=${parsedStation.ocppConnectionName}&tenantId=${parsedStation.tenantId}`,
|
||||
data: undefined,
|
||||
setLoading,
|
||||
ocppVersion: null,
|
||||
method: HttpMethod.Delete,
|
||||
});
|
||||
|
||||
dispatch(closeModal());
|
||||
};
|
||||
|
||||
const onCancel = async () => {
|
||||
dispatch(closeModal());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{translate('ChargingStations.forceDisconnectMessage')}{' '}
|
||||
<span className="font-medium">{parsedStation.ocppConnectionName}</span>?
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{translate('ChargingStations.forceDisconnectCaution')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onCancel} disabled={loading}>
|
||||
{translate('buttons.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onOkay} disabled={loading}>
|
||||
{loading ? translate('buttons.saving') : translate('buttons.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import { type ChargingStationDto } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { FormField } from '@lib/client/components/form/field';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { Textarea } from '@lib/client/components/ui/textarea';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface DataTransferModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
const DataTransferSchema = z.object({
|
||||
vendorId: z.string().min(1, 'Vendor ID is required'),
|
||||
messageId: z.string().optional(),
|
||||
data: z.string().optional(),
|
||||
});
|
||||
|
||||
type DataTransferFormData = z.infer<typeof DataTransferSchema>;
|
||||
|
||||
export const DataTransferModal = ({ station }: DataTransferModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
const ocppVersion = parsedStation.protocol;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(DataTransferSchema),
|
||||
defaultValues: {
|
||||
vendorId: '',
|
||||
messageId: '',
|
||||
data: '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: DataTransferFormData) => {
|
||||
if (!parsedStation?.ocppConnectionName) {
|
||||
console.error('Error: Cannot submit Data Transfer request because station ID is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
vendorId: values.vendorId,
|
||||
};
|
||||
|
||||
if (values.messageId) {
|
||||
data.messageId = values.messageId;
|
||||
}
|
||||
|
||||
if (values.data) {
|
||||
data.data = values.data;
|
||||
}
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/configuration/dataTransfer?identifier=${parsedStation.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion,
|
||||
}).then(() => {
|
||||
form.reset({
|
||||
vendorId: '',
|
||||
messageId: '',
|
||||
data: '',
|
||||
});
|
||||
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={handleSubmit}
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
hideCancel
|
||||
>
|
||||
<FormField control={form.control} label="Vendor ID" name="vendorId" required>
|
||||
<Input placeholder="Vendor specific implementation identifier" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Message ID" name="messageId">
|
||||
<Input placeholder="Specific message or implementation identifier" />
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} label="Data" name="data">
|
||||
<Textarea placeholder="Data to send (freeform text)" />
|
||||
</FormField>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { OtherCommandsModal } from '@lib/client/components/modals/other-commands/other.commands.modal';
|
||||
import { RemoteStartTransactionModal } from '@lib/client/components/modals/remote-start/remote.start.modal';
|
||||
import { RemoteStopTransactionModal } from '@lib/client/components/modals/remote-stop/remote.stop.modal';
|
||||
import { ResetModal } from '@lib/client/components/modals/reset/reset.modal';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@lib/client/components/ui/dialog';
|
||||
import { closeModal, selectModal } from '@lib/utils/store/modal.slice';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
// Shared Modals (same for both OCPP versions)
|
||||
import { DataTransferModal } from '@lib/client/components/modals/data-transfer/data.transfer.modal';
|
||||
// OCPP 1.6 Modals
|
||||
import { ChangeAvailabilityModal as ChangeAvailabilityModal16 } from '@lib/client/components/modals/1.6/change-availability/change.availability.modal';
|
||||
import { ChangeConfigurationModal } from '@lib/client/components/modals/1.6/change-configuration/change.configuration.modal';
|
||||
import { GetConfigurationModal } from '@lib/client/components/modals/1.6/get-configuration/get.configuration.modal';
|
||||
import { TriggerMessageModal as TriggerMessageModal16 } from '@lib/client/components/modals/1.6/trigger-message/trigger.message.modal';
|
||||
import { UpdateFirmwareModal as UpdateFirmwareModal16 } from '@lib/client/components/modals/1.6/update-firmware/update.firmware.modal'; // OCPP 2.0.1 Modals
|
||||
import { CertificateSignedModal } from '@lib/client/components/modals/2.0.1/certificate-signed/certificate.signed.modal';
|
||||
import { ChangeAvailabilityModal as ChangeAvailabilityModal201 } from '@lib/client/components/modals/2.0.1/change-availability/change.availability.modal';
|
||||
import { ClearCacheModal } from '@lib/client/components/modals/2.0.1/clear-cache/clear.cache.modal';
|
||||
import { CustomerInformationModal } from '@lib/client/components/modals/2.0.1/customer-information/customer.information.modal';
|
||||
import { DeleteCertificateModal } from '@lib/client/components/modals/2.0.1/delete-certificate/delete.certificate.modal';
|
||||
import { DeleteStationNetworkProfilesModal } from '@lib/client/components/modals/2.0.1/delete-station-network-profiles/delete.station.network.profiles.modal';
|
||||
import { GetBaseReportModal } from '@lib/client/components/modals/2.0.1/get-base-report/get.base.report.modal';
|
||||
import { GetInstalledCertificateIdsModal } from '@lib/client/components/modals/2.0.1/get-installed-certificate-ids/get.installed.certificate.ids.modal';
|
||||
import { GetLogsModal } from '@lib/client/components/modals/2.0.1/get-logs/get.logs.modal';
|
||||
import { GetTransactionStatusModal } from '@lib/client/components/modals/2.0.1/get-transaction-status/get.transaction.status.modal';
|
||||
import { GetVariablesModal } from '@lib/client/components/modals/2.0.1/get-variables/get.variables.modal';
|
||||
import { InstallCertificateModal } from '@lib/client/components/modals/2.0.1/install-certificate/install.certificate.modal';
|
||||
import { SetNetworkProfileModal } from '@lib/client/components/modals/2.0.1/set-network-profile/set.network.profile.modal';
|
||||
import { SetVariablesModal } from '@lib/client/components/modals/2.0.1/set-variables/set.variables.modal';
|
||||
import { TriggerMessageModal as TriggerMessageModal201 } from '@lib/client/components/modals/2.0.1/trigger-message/trigger.message.modal';
|
||||
import { UnlockConnectorModal } from '@lib/client/components/modals/2.0.1/unlock-connector/unlock.connector.modal';
|
||||
import { UpdateAuthPasswordModal } from '@lib/client/components/modals/2.0.1/update-auth-password/update.auth.password.modal';
|
||||
import { UpdateFirmwareModal as UpdateFirmwareModal201 } from '@lib/client/components/modals/2.0.1/update-firmware/update.firmware.modal';
|
||||
import { ToggleStationOnlineModal } from '@lib/client/components/modals/toggle-status/toggle.station.online.modal';
|
||||
import { ToggleTransactionActiveModal } from '@lib/client/components/modals/toggle-status/toggle.transaction.active.modal';
|
||||
import { ModalComponentType } from '@lib/client/components/modals/modal.types';
|
||||
import { ForceDisconnectModal } from './admin/force-disconnect/force.disconnect.modal';
|
||||
import { GetDiagnosticsModal } from './1.6/get-diagnostics/get.diagnostics.modal';
|
||||
import { isNullOrUndefined } from '@lib/utils/assertion';
|
||||
|
||||
const MODAL_COMPONENTS: Partial<{
|
||||
[key in ModalComponentType]: React.FC<any>;
|
||||
}> = {
|
||||
// Admin Commands
|
||||
[ModalComponentType.forceDisconnect]: ForceDisconnectModal,
|
||||
// Shared Commands
|
||||
[ModalComponentType.remoteStart]: RemoteStartTransactionModal,
|
||||
[ModalComponentType.remoteStop]: RemoteStopTransactionModal,
|
||||
[ModalComponentType.reset]: ResetModal,
|
||||
[ModalComponentType.otherCommands]: OtherCommandsModal,
|
||||
// Shared
|
||||
[ModalComponentType.dataTransfer]: DataTransferModal,
|
||||
// OCPP 1.6
|
||||
[ModalComponentType.changeAvailability16]: ChangeAvailabilityModal16,
|
||||
[ModalComponentType.changeConfiguration16]: ChangeConfigurationModal,
|
||||
[ModalComponentType.getConfiguration16]: GetConfigurationModal,
|
||||
[ModalComponentType.getDiagnostics16]: GetDiagnosticsModal,
|
||||
[ModalComponentType.triggerMessage16]: TriggerMessageModal16,
|
||||
[ModalComponentType.updateFirmware16]: UpdateFirmwareModal16,
|
||||
// OCPP 2.0.1
|
||||
[ModalComponentType.certificateSigned]: CertificateSignedModal,
|
||||
[ModalComponentType.changeAvailability201]: ChangeAvailabilityModal201,
|
||||
[ModalComponentType.clearCache]: ClearCacheModal,
|
||||
[ModalComponentType.customerInformation]: CustomerInformationModal,
|
||||
[ModalComponentType.deleteCertificate]: DeleteCertificateModal,
|
||||
[ModalComponentType.deleteStationNetworkProfiles]: DeleteStationNetworkProfilesModal,
|
||||
[ModalComponentType.getBaseReport]: GetBaseReportModal,
|
||||
[ModalComponentType.getInstalledCertificateIds]: GetInstalledCertificateIdsModal,
|
||||
[ModalComponentType.getLogs]: GetLogsModal,
|
||||
[ModalComponentType.getTransactionStatus]: GetTransactionStatusModal,
|
||||
[ModalComponentType.getVariables]: GetVariablesModal,
|
||||
[ModalComponentType.installCertificate]: InstallCertificateModal,
|
||||
[ModalComponentType.setNetworkProfile]: SetNetworkProfileModal,
|
||||
[ModalComponentType.setVariables]: SetVariablesModal,
|
||||
[ModalComponentType.triggerMessage201]: TriggerMessageModal201,
|
||||
[ModalComponentType.unlockConnector]: UnlockConnectorModal,
|
||||
[ModalComponentType.updateAuthPassword]: UpdateAuthPasswordModal,
|
||||
[ModalComponentType.updateFirmware201]: UpdateFirmwareModal201,
|
||||
// Status Toggle Confirmations
|
||||
[ModalComponentType.toggleStationOnlineStatus]: ToggleStationOnlineModal,
|
||||
[ModalComponentType.toggleTransactionActiveStatus]: ToggleTransactionActiveModal,
|
||||
};
|
||||
|
||||
const largeModals = [
|
||||
ModalComponentType.getVariables,
|
||||
ModalComponentType.setVariables,
|
||||
ModalComponentType.setNetworkProfile,
|
||||
];
|
||||
|
||||
const AppModal = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { isOpen, title, modalComponentType, modalComponentProps } = useSelector(selectModal);
|
||||
|
||||
const ModalComponent = !isNullOrUndefined(modalComponentType)
|
||||
? MODAL_COMPONENTS[modalComponentType]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && dispatch(closeModal())}>
|
||||
<DialogContent
|
||||
className={`overflow-auto max-h-150! ${modalComponentType && largeModals.includes(modalComponentType) ? 'max-w-300!' : ''}`}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription></DialogDescription>
|
||||
{ModalComponent && <ModalComponent {...modalComponentProps} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppModal;
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/**
|
||||
* Enum of all modal component types
|
||||
* This file contains ONLY the enum to avoid circular dependencies
|
||||
*/
|
||||
export enum ModalComponentType {
|
||||
// Admin Commands
|
||||
forceDisconnect,
|
||||
// Common Commands
|
||||
remoteStart,
|
||||
remoteStop,
|
||||
reset,
|
||||
otherCommands,
|
||||
// OCPP 1.6 Commands
|
||||
changeAvailability16,
|
||||
changeConfiguration16,
|
||||
getConfiguration16,
|
||||
getDiagnostics16,
|
||||
triggerMessage16,
|
||||
updateFirmware16,
|
||||
// Shared Commands (same modal for both OCPP versions)
|
||||
dataTransfer,
|
||||
// OCPP 2.0.1 Commands
|
||||
certificateSigned,
|
||||
changeAvailability201,
|
||||
clearCache,
|
||||
customerInformation,
|
||||
deleteCertificate,
|
||||
deleteStationNetworkProfiles,
|
||||
getBaseReport,
|
||||
getInstalledCertificateIds,
|
||||
getLogs,
|
||||
getTransactionStatus,
|
||||
getVariables,
|
||||
installCertificate,
|
||||
setNetworkProfile,
|
||||
setVariables,
|
||||
triggerMessage201,
|
||||
unlockConnector,
|
||||
updateAuthPassword,
|
||||
updateFirmware201,
|
||||
// Status Toggle Confirmations
|
||||
toggleStationOnlineStatus,
|
||||
toggleTransactionActiveStatus,
|
||||
firstLoginHelp,
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import type { ChargingStationDto } from '@citrineos/base';
|
||||
import {
|
||||
OCPP1_6_COMMANDS_REGISTRY,
|
||||
type CommandDefinition,
|
||||
} from '@lib/client/components/modals/1.6/commands.registry';
|
||||
import { Button } from '@lib/client/components/ui/button';
|
||||
import type { ListCanReturnType } from '@lib/utils/access.types';
|
||||
import { ActionType, ResourceType } from '@lib/utils/access.types';
|
||||
import { closeModal, openModal } from '@lib/utils/store/modal.slice';
|
||||
import { useCan } from '@refinedev/core';
|
||||
import { instanceToPlain } from 'class-transformer';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
export interface OCPP1_6_CommandsProps {
|
||||
station: ChargingStationDto;
|
||||
}
|
||||
|
||||
export const OCPP1_6_Commands = ({ station }: OCPP1_6_CommandsProps) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleCommandClick = (commandDef: CommandDefinition) => {
|
||||
dispatch(
|
||||
openModal({
|
||||
title: commandDef.displayName,
|
||||
modalComponentType: commandDef.modalType,
|
||||
modalComponentProps: { station: instanceToPlain(station) },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const commandsToExclude: string[] = [];
|
||||
|
||||
const { data } = useCan({
|
||||
resource: ResourceType.CHARGING_STATIONS,
|
||||
action: ActionType.COMMAND,
|
||||
params: {
|
||||
id: station.ocppConnectionName,
|
||||
commandType: 'otherCommands',
|
||||
},
|
||||
});
|
||||
|
||||
const listData = data as ListCanReturnType;
|
||||
if (!data?.can) {
|
||||
return null;
|
||||
} else if (listData?.meta?.exceptions) {
|
||||
for (const exception of listData.meta.exceptions) {
|
||||
if (exception.param === 'commandType') {
|
||||
commandsToExclude.push(...exception.values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(OCPP1_6_COMMANDS_REGISTRY).map(([commandKey, commandDef]) => (
|
||||
<Button
|
||||
key={commandKey}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => handleCommandClick(commandDef)}
|
||||
>
|
||||
{commandDef.displayName}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import type { ChargingStationDto } from '@citrineos/base';
|
||||
import {
|
||||
OCPP2_0_1_COMMANDS_REGISTRY,
|
||||
type CommandDefinition,
|
||||
} from '@lib/client/components/modals/2.0.1/commands.registry';
|
||||
import { Button } from '@lib/client/components/ui/button';
|
||||
import type { ListCanReturnType } from '@lib/utils/access.types';
|
||||
import { ActionType, ResourceType } from '@lib/utils/access.types';
|
||||
import { closeModal, openModal } from '@lib/utils/store/modal.slice';
|
||||
import { useCan } from '@refinedev/core';
|
||||
import { instanceToPlain } from 'class-transformer';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
export interface OCPP2_0_1_CommandsProps {
|
||||
station: ChargingStationDto;
|
||||
}
|
||||
|
||||
export const OCPP2_0_1_Commands = ({ station }: OCPP2_0_1_CommandsProps) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleCommandClick = (commandDef: CommandDefinition) => {
|
||||
dispatch(
|
||||
openModal({
|
||||
title: commandDef.displayName,
|
||||
modalComponentType: commandDef.modalType,
|
||||
modalComponentProps: { station: instanceToPlain(station) },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const commandsToExclude: string[] = [];
|
||||
|
||||
const { data } = useCan({
|
||||
resource: ResourceType.CHARGING_STATIONS,
|
||||
action: ActionType.COMMAND,
|
||||
params: {
|
||||
id: station.ocppConnectionName,
|
||||
commandType: 'otherCommands',
|
||||
},
|
||||
});
|
||||
|
||||
const listData = data as ListCanReturnType;
|
||||
if (!data?.can) {
|
||||
return null;
|
||||
} else if (listData?.meta?.exceptions) {
|
||||
for (const exception of listData.meta.exceptions) {
|
||||
if (exception.param === 'commandType') {
|
||||
commandsToExclude.push(...exception.values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="size-full overflow-hidden space-y-4">
|
||||
{Object.entries(OCPP2_0_1_COMMANDS_REGISTRY).map(([commandKey, commandDef]) => (
|
||||
<Button
|
||||
key={commandKey}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => handleCommandClick(commandDef)}
|
||||
>
|
||||
{commandDef.displayName}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ChargingStationDto } from '@citrineos/base';
|
||||
import { OCPPVersion } from '@citrineos/base';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import { selectIsModalOpen } from '@lib/utils/store/modal.slice';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { OCPP1_6_Commands } from './1.6';
|
||||
import { OCPP2_0_1_Commands } from './2.0.1';
|
||||
|
||||
export interface OtherCommandsModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
export const OtherCommandsModal = ({ station }: OtherCommandsModalProps) => {
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const isModalOpen = useSelector(selectIsModalOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isModalOpen]);
|
||||
|
||||
// Dynamically render the appropriate component based on protocol version
|
||||
const renderCommandsByProtocol = () => {
|
||||
switch (parsedStation.protocol) {
|
||||
case OCPPVersion.OCPP1_6:
|
||||
return <OCPP1_6_Commands station={parsedStation} />;
|
||||
case OCPPVersion.OCPP2_0_1:
|
||||
case OCPPVersion.OCPP2_1:
|
||||
return <OCPP2_0_1_Commands station={parsedStation} />;
|
||||
default:
|
||||
return <div>Unsupported protocol version: {parsedStation.protocol}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && renderCommandsByProtocol()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import type { AuthorizationDto, ChargingStationDto } from '@citrineos/base';
|
||||
import { AuthorizationProps, BaseProps, OCPPVersion } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { ComboboxFormField, FormField } from '@lib/client/components/form/field';
|
||||
import { ConnectorSelector } from '@lib/client/components/modals/shared/connector-selector/connector.selector';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { AUTHORIZATIONS_LIST_QUERY } from '@lib/queries/authorizations';
|
||||
import { ResourceType } from '@lib/utils/access.types';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useSelect } from '@refinedev/core';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import React, { useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface OCPP1_6_RemoteStartProps {
|
||||
station: ChargingStationDto;
|
||||
}
|
||||
|
||||
const RemoteStartSchema = z.object({
|
||||
remoteStartId: z.coerce.number<number>().min(0, 'Remote Start ID must be at least 0'),
|
||||
idTag: z.string().min(1, 'ID Token is required'),
|
||||
connectorId: z.number().optional(),
|
||||
});
|
||||
|
||||
type RemoteStartFormData = z.infer<typeof RemoteStartSchema>;
|
||||
|
||||
export const OCPP1_6_RemoteStart = ({ station }: OCPP1_6_RemoteStartProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(RemoteStartSchema),
|
||||
defaultValues: {
|
||||
remoteStartId: 0,
|
||||
idTag: '',
|
||||
connectorId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
options: authorizationOptions,
|
||||
onSearch: authorizationOnSearch,
|
||||
query: authorizationQueryResult,
|
||||
} = useSelect<AuthorizationDto>({
|
||||
resource: ResourceType.AUTHORIZATIONS,
|
||||
optionLabel: 'idToken',
|
||||
optionValue: 'idToken',
|
||||
meta: {
|
||||
gqlQuery: AUTHORIZATIONS_LIST_QUERY,
|
||||
gqlVariables: { offset: 0, limit: 10 },
|
||||
},
|
||||
sorters: [{ field: BaseProps.updatedAt, order: 'desc' }],
|
||||
pagination: { mode: 'off' },
|
||||
onSearch: (value: string) => [
|
||||
{
|
||||
operator: 'or',
|
||||
value: [
|
||||
{
|
||||
field: AuthorizationProps.idToken,
|
||||
operator: 'contains',
|
||||
value,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const handleIdTokenSelection = (value: string) => {
|
||||
form.setValue('idTag', value, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConnectorSelection = (value: any) => {
|
||||
form.setValue('connectorId', Number(value), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onFinish = (values: RemoteStartFormData) => {
|
||||
const data = {
|
||||
connectorId: values.connectorId,
|
||||
idTag: values.idTag,
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/evdriver/remoteStartTransaction?identifier=${station.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
ocppVersion: OCPPVersion.OCPP1_6,
|
||||
setLoading,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading || authorizationQueryResult.isLoading}
|
||||
submitHandler={onFinish}
|
||||
hideCancel
|
||||
submitButtonVariant={FormButtonVariants.confirm}
|
||||
submitButtonLabel="Start"
|
||||
>
|
||||
<FormField control={form.control} label="Remote Start ID" name="remoteStartId">
|
||||
<Input type="number" min={0} />
|
||||
</FormField>
|
||||
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
label="ID Token"
|
||||
name="idTag"
|
||||
options={authorizationOptions}
|
||||
onSelect={handleIdTokenSelection}
|
||||
onSearch={authorizationOnSearch}
|
||||
placeholder="Search ID Token"
|
||||
isLoading={authorizationQueryResult.isLoading}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="connectorId"
|
||||
render={({ field }) => (
|
||||
<ConnectorSelector
|
||||
station={station}
|
||||
value={field.value ?? undefined}
|
||||
onSelect={handleConnectorSelection}
|
||||
isOptional
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import type {
|
||||
AuthorizationDto,
|
||||
ChargingStationDto,
|
||||
ChargingStationSequenceDto,
|
||||
} from '@citrineos/base';
|
||||
import { AuthorizationProps, BaseProps, ChargingStationSequenceTypeEnum } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { ComboboxFormField, FormField } from '@lib/client/components/form/field';
|
||||
import { EvseSelector } from '@lib/client/components/modals/shared/evse-selector/evse.selector';
|
||||
import { Input } from '@lib/client/components/ui/input';
|
||||
import { ChargingStationSequenceClass } from '@lib/cls/charging.station.sequence.dto';
|
||||
import { AUTHORIZATIONS_LIST_QUERY } from '@lib/queries/authorizations';
|
||||
import { CHARGING_STATION_SEQUENCES_GET_QUERY } from '@lib/queries/charging.station.sequences';
|
||||
import { ResourceType } from '@lib/utils/access.types';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useCustom, useSelect } from '@refinedev/core';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { isEmpty } from '@lib/utils/assertion';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface OCPP2_0_1_RemoteStartProps {
|
||||
station: ChargingStationDto;
|
||||
}
|
||||
|
||||
export const RemoteStartSchema = z.object({
|
||||
remoteStartId: z.coerce.number<number>().min(0, 'Remote Start ID must be at least 0'),
|
||||
authorization: z.string().min(1, 'Authorization is required'),
|
||||
evse: z.string().optional(), // { id, evseTypeId }
|
||||
});
|
||||
export type RemoteStartFormData = z.infer<typeof RemoteStartSchema>;
|
||||
|
||||
export const OCPP2_0_1_RemoteStart = ({ station }: OCPP2_0_1_RemoteStartProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(RemoteStartSchema),
|
||||
defaultValues: {
|
||||
remoteStartId: 0,
|
||||
authorization: '',
|
||||
evse: '',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
query: { data: requestIdResponse, isLoading: isLoadingRequestId },
|
||||
} = useCustom<ChargingStationSequenceDto>({
|
||||
meta: {
|
||||
gqlQuery: CHARGING_STATION_SEQUENCES_GET_QUERY,
|
||||
gqlVariables: {
|
||||
stationId: station.id,
|
||||
type: ChargingStationSequenceTypeEnum.remoteStartId,
|
||||
},
|
||||
},
|
||||
queryOptions: {
|
||||
select: (data: any) => {
|
||||
return {
|
||||
data: !data.data.ChargingStationSequences[0]
|
||||
? undefined
|
||||
: plainToInstance(ChargingStationSequenceClass, data.data.ChargingStationSequences[0]),
|
||||
};
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const {
|
||||
options: authorizationOptions,
|
||||
onSearch: authorizationOnSearch,
|
||||
query: authorizationQueryResult,
|
||||
} = useSelect<AuthorizationDto>({
|
||||
resource: ResourceType.AUTHORIZATIONS,
|
||||
optionLabel: 'idToken',
|
||||
optionValue: (item) => {
|
||||
return JSON.stringify({
|
||||
idToken: item.idToken,
|
||||
idTokenType: item.idTokenType,
|
||||
additionalInfo: item.additionalInfo,
|
||||
});
|
||||
},
|
||||
meta: {
|
||||
gqlQuery: AUTHORIZATIONS_LIST_QUERY,
|
||||
gqlVariables: { offset: 0, limit: 10 },
|
||||
},
|
||||
sorters: [{ field: BaseProps.updatedAt, order: 'desc' }],
|
||||
pagination: { mode: 'off' },
|
||||
onSearch: (value) => [
|
||||
{
|
||||
operator: 'or',
|
||||
value: [
|
||||
{
|
||||
field: AuthorizationProps.idToken,
|
||||
operator: 'contains',
|
||||
value,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (requestIdResponse && requestIdResponse.data && requestIdResponse.data.value) {
|
||||
form.setValue('remoteStartId', requestIdResponse.data.value);
|
||||
}
|
||||
}, [requestIdResponse, form]);
|
||||
|
||||
const handleEvseSelection = (value: string) => {
|
||||
form.setValue('evse', value);
|
||||
};
|
||||
|
||||
const onFinish = (values: RemoteStartFormData) => {
|
||||
const parsedAuthorization = JSON.parse(values.authorization);
|
||||
const parsedEvse = values.evse ? JSON.parse(values.evse) : undefined;
|
||||
const parsedAdditionalInfo = isEmpty(parsedAuthorization.additionalInfo)
|
||||
? undefined
|
||||
: parsedAuthorization.additionalInfo;
|
||||
|
||||
const data = {
|
||||
remoteStartId: values.remoteStartId,
|
||||
evseId: parsedEvse?.evseTypeId,
|
||||
idToken: {
|
||||
idToken: parsedAuthorization.idToken,
|
||||
type: parsedAuthorization.idTokenType!,
|
||||
additionalInfo: parsedAdditionalInfo,
|
||||
},
|
||||
};
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/evdriver/requestStartTransaction?identifier=${station.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: station.protocol,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading || authorizationQueryResult.isLoading || isLoadingRequestId}
|
||||
submitHandler={onFinish}
|
||||
hideCancel
|
||||
submitButtonVariant={FormButtonVariants.confirm}
|
||||
submitButtonLabel="Start"
|
||||
>
|
||||
<FormField control={form.control} label="Remote Start ID" name="remoteStartId">
|
||||
<Input type="number" min={0} />
|
||||
</FormField>
|
||||
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
label="Authorization"
|
||||
name="authorization"
|
||||
options={authorizationOptions}
|
||||
onSearch={authorizationOnSearch}
|
||||
placeholder="Select Authorization"
|
||||
searchPlaceholder="Search Authorizations"
|
||||
required
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="evse"
|
||||
render={({ field }) => (
|
||||
<EvseSelector
|
||||
station={station}
|
||||
value={field.value ?? undefined}
|
||||
onSelect={handleEvseSelection}
|
||||
isOptional
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ChargingStationDto } from '@citrineos/base';
|
||||
import { OCPPVersion } from '@citrineos/base';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo } from 'react';
|
||||
import { OCPP1_6_RemoteStart } from './1.6';
|
||||
import { OCPP2_0_1_RemoteStart } from './2.0.1';
|
||||
|
||||
export interface RemoteStartTransactionModalProps {
|
||||
station: any;
|
||||
}
|
||||
|
||||
export const RemoteStartTransactionModal = ({ station }: RemoteStartTransactionModalProps) => {
|
||||
const parsedStation: ChargingStationDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationDto;
|
||||
|
||||
// Dynamically render the appropriate component based on protocol version
|
||||
const renderCommandsByProtocol = () => {
|
||||
switch (parsedStation.protocol) {
|
||||
case OCPPVersion.OCPP1_6:
|
||||
return <OCPP1_6_RemoteStart station={parsedStation} />;
|
||||
case OCPPVersion.OCPP2_0_1:
|
||||
case OCPPVersion.OCPP2_1:
|
||||
return <OCPP2_0_1_RemoteStart station={parsedStation} />;
|
||||
default:
|
||||
return <div>Unsupported protocol version: {parsedStation.protocol}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return <div>{renderCommandsByProtocol()}</div>;
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import type { TransactionDto } from '@citrineos/base';
|
||||
import { OCPPVersion } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { ComboboxFormField } from '@lib/client/components/form/field';
|
||||
import type { ChargingStationWithTransactionsDto } from '@lib/client/components/modals/remote-stop/remote.stop.modal';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface OCPP1_6_RemoteStopProps {
|
||||
station: ChargingStationWithTransactionsDto;
|
||||
}
|
||||
|
||||
const RemoteStopSchema = z.object({
|
||||
transactionId: z.string().min(1, 'Transaction is required'),
|
||||
});
|
||||
|
||||
type RemoteStopFormData = z.infer<typeof RemoteStopSchema>;
|
||||
|
||||
export const OCPP1_6_RemoteStop = ({ station }: OCPP1_6_RemoteStopProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(RemoteStopSchema),
|
||||
defaultValues: {
|
||||
transactionId: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onFinish = (values: RemoteStopFormData) => {
|
||||
const data = { transactionId: values.transactionId };
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/evdriver/remoteStopTransaction?identifier=${station.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
ocppVersion: OCPPVersion.OCPP1_6,
|
||||
setLoading,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
// Set initial value when transactions are loaded
|
||||
useEffect(() => {
|
||||
if (station.transactions && station.transactions.length > 0) {
|
||||
form.setValue('transactionId', station.transactions[0].transactionId);
|
||||
}
|
||||
}, [station, form]);
|
||||
|
||||
// Handle the case when there are no active transactions
|
||||
const hasNoActiveTransactions = station.transactions && station.transactions.length === 0;
|
||||
|
||||
return hasNoActiveTransactions ? (
|
||||
<div>No active transactions found for this charging station.</div>
|
||||
) : (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={onFinish}
|
||||
hideCancel
|
||||
submitButtonVariant={FormButtonVariants.delete}
|
||||
submitButtonLabel="Stop"
|
||||
>
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
label="Active Transactions"
|
||||
name="transactionId"
|
||||
options={station.transactions.map((transaction: TransactionDto) => ({
|
||||
label: transaction.transactionId,
|
||||
value: transaction.transactionId,
|
||||
}))}
|
||||
placeholder="Select Transaction"
|
||||
searchPlaceholder="Search Transactions"
|
||||
required
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import type { EvseDto, TransactionDto } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { ComboboxFormField } from '@lib/client/components/form/field';
|
||||
import type { ChargingStationWithTransactionsDto } from '@lib/client/components/modals/remote-stop/remote.stop.modal';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface OCPP2_0_1_RemoteStopProps {
|
||||
station: ChargingStationWithTransactionsDto;
|
||||
}
|
||||
|
||||
const RemoteStopSchema = z.object({
|
||||
transactionId: z.string().min(1, 'Transaction is required'),
|
||||
});
|
||||
|
||||
type RemoteStopFormData = z.infer<typeof RemoteStopSchema>;
|
||||
|
||||
const unknownEvse = 'Unknown';
|
||||
|
||||
export const OCPP2_0_1_RemoteStop = ({ station }: OCPP2_0_1_RemoteStopProps) => {
|
||||
const evseMap: Map<number, EvseDto> = useMemo(() => {
|
||||
if (!station.evses) return new Map<number, EvseDto>();
|
||||
return new Map(station.evses.map((evse) => [evse.id!, evse]));
|
||||
}, [station.evses]);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(RemoteStopSchema),
|
||||
defaultValues: {
|
||||
transactionId: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onFinish = (values: RemoteStopFormData) => {
|
||||
const data = { transactionId: values.transactionId };
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/evdriver/requestStopTransaction?identifier=${station.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
ocppVersion: station.protocol,
|
||||
setLoading,
|
||||
}).then(() => {
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
// Set initial value when transactions are loaded
|
||||
useEffect(() => {
|
||||
if (station.transactions && station.transactions.length > 0) {
|
||||
form.setValue('transactionId', station.transactions[0].transactionId);
|
||||
}
|
||||
}, [station, form]);
|
||||
|
||||
// Filter out inactive transactions
|
||||
const activeTransactions = station.transactions
|
||||
? station.transactions.filter((tx: TransactionDto) => tx.isActive)
|
||||
: [];
|
||||
|
||||
// Handle the case when there are no active transactions
|
||||
const hasNoActiveTransactions = activeTransactions.length === 0;
|
||||
|
||||
return hasNoActiveTransactions ? (
|
||||
<div>No active transactions found for this charging station.</div>
|
||||
) : (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={onFinish}
|
||||
hideCancel
|
||||
submitButtonVariant={FormButtonVariants.delete}
|
||||
submitButtonLabel="Stop"
|
||||
>
|
||||
<ComboboxFormField
|
||||
control={form.control}
|
||||
label="Active Transactions"
|
||||
name="transactionId"
|
||||
options={station.transactions.map((transaction: TransactionDto) => ({
|
||||
label: `EVSE: ${(transaction.evseId ? evseMap.get(transaction.evseId)?.id : unknownEvse) ?? unknownEvse} - ${transaction.transactionId}`,
|
||||
value: transaction.transactionId,
|
||||
}))}
|
||||
placeholder="Select Transaction"
|
||||
searchPlaceholder="Search Transactions"
|
||||
required
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { ChargingStationSchema, OCPPVersion, TransactionSchema } from '@citrineos/base';
|
||||
import { ChargingStationClass } from '@lib/cls/charging.station.dto';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { useMemo } from 'react';
|
||||
import type { z } from 'zod';
|
||||
import { OCPP1_6_RemoteStop } from './1.6';
|
||||
import { OCPP2_0_1_RemoteStop } from './2.0.1';
|
||||
|
||||
const ChargingStationWithTransactionsSchema = ChargingStationSchema.extend({
|
||||
transactions: TransactionSchema.array(),
|
||||
});
|
||||
export type ChargingStationWithTransactionsDto = z.infer<
|
||||
typeof ChargingStationWithTransactionsSchema
|
||||
>;
|
||||
|
||||
export interface RemoteStopTransactionModalProps {
|
||||
station: ChargingStationWithTransactionsDto;
|
||||
}
|
||||
|
||||
export const RemoteStopTransactionModal = ({ station }: RemoteStopTransactionModalProps) => {
|
||||
const parsedStation: ChargingStationWithTransactionsDto = useMemo(
|
||||
() => plainToInstance(ChargingStationClass, station),
|
||||
[station],
|
||||
) as ChargingStationWithTransactionsDto;
|
||||
|
||||
// Dynamically render the appropriate component based on protocol version
|
||||
const renderCommandsByProtocol = () => {
|
||||
switch (parsedStation.protocol) {
|
||||
case OCPPVersion.OCPP1_6:
|
||||
return <OCPP1_6_RemoteStop station={parsedStation} />;
|
||||
case OCPPVersion.OCPP2_0_1:
|
||||
case OCPPVersion.OCPP2_1:
|
||||
return <OCPP2_0_1_RemoteStop station={parsedStation} />;
|
||||
default:
|
||||
return <div>Unsupported protocol version: {parsedStation.protocol}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return <div>{renderCommandsByProtocol()}</div>;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
'use client';
|
||||
|
||||
import type { ChargingStationDto } from '@citrineos/base';
|
||||
import { OCPP1_6, OCPPVersion } from '@citrineos/base';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form } from '@lib/client/components/form';
|
||||
import { SelectFormField } from '@lib/client/components/form/field';
|
||||
import type { MessageConfirmation } from '@lib/utils/MessageConfirmation';
|
||||
import { triggerMessageAndHandleResponse } from '@lib/utils/messages.utils';
|
||||
import { closeModal } from '@lib/utils/store/modal.slice';
|
||||
import { useForm } from '@refinedev/react-hook-form';
|
||||
import { useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import z from 'zod';
|
||||
import { FormButtonVariants } from '@lib/client/components/buttons/form.button';
|
||||
import { useTenantId } from '@lib/client/hooks/useTenantId';
|
||||
|
||||
export interface OCPP1_6_ResetProps {
|
||||
station: ChargingStationDto;
|
||||
}
|
||||
|
||||
const ResetSchema = z.object({
|
||||
type: z.enum(OCPP1_6.ResetRequestType, {
|
||||
message: 'Please select a reset type',
|
||||
}),
|
||||
});
|
||||
|
||||
type ResetFormData = z.infer<typeof ResetSchema>;
|
||||
|
||||
const resetTypes = Object.keys(OCPP1_6.ResetRequestType);
|
||||
|
||||
export const OCPP1_6_Reset = ({ station }: OCPP1_6_ResetProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ResetSchema),
|
||||
defaultValues: {
|
||||
type: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: ResetFormData) => {
|
||||
const data = { type: values.type };
|
||||
|
||||
triggerMessageAndHandleResponse<MessageConfirmation[]>({
|
||||
url: `/configuration/reset?identifier=${station.ocppConnectionName}&tenantId=${tenantId}`,
|
||||
data,
|
||||
setLoading,
|
||||
ocppVersion: OCPPVersion.OCPP1_6,
|
||||
}).then(() => {
|
||||
form.reset();
|
||||
dispatch(closeModal());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...form}
|
||||
loading={loading}
|
||||
submitHandler={handleSubmit}
|
||||
hideCancel
|
||||
submitButtonVariant={FormButtonVariants.submit}
|
||||
submitButtonLabel="Reset"
|
||||
>
|
||||
<SelectFormField
|
||||
control={form.control}
|
||||
label="Reset Type"
|
||||
name="type"
|
||||
options={resetTypes}
|
||||
placeholder="Select Reset Type"
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user