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
15
tools/citrineos-core-main/apps/operator-ui/.env.test
Normal file
@@ -0,0 +1,15 @@
|
||||
# Playwright E2E environment. Committed intentionally so `npm run test:e2e`
|
||||
# and the GitHub Actions tests workflow run with zero configuration against
|
||||
# the local docker-compose stack. Values must match the defaults seeded by
|
||||
# citrineos-core's docker-compose and the admin creds in .env.local.
|
||||
|
||||
E2E_BASE_URL=http://localhost:3000
|
||||
HASURA_URL=http://localhost:8090/v1/graphql
|
||||
CITRINE_CORE_URL=http://localhost:8080
|
||||
E2E_TENANT_ID=1
|
||||
E2E_ADMIN_EMAIL=admin@citrineos.com
|
||||
E2E_ADMIN_PASSWORD=CitrineOS!
|
||||
E2E_AUTH_PROVIDER=generic
|
||||
|
||||
# Optional. Used by the Phase 2 schema-drift guard if set.
|
||||
HASURA_ADMIN_SECRET=CitrineOS!
|
||||
40
tools/citrineos-core-main/apps/operator-ui/Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
||||
# SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Build context is the monorepo root.
|
||||
|
||||
FROM --platform=${BUILDPLATFORM:-linux/amd64} node:24.16.0 AS builder
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter "@citrineos/operator-ui..." build
|
||||
|
||||
FROM node:24.16.0-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
# Next.js standalone output is rooted at outputFileTracingRoot (monorepo root),
|
||||
# so server.js lives at apps/operator-ui/server.js within the standalone tree.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/operator-ui/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/operator-ui/.next/static ./apps/operator-ui/.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/operator-ui/public ./apps/operator-ui/public
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "apps/operator-ui/server.js"]
|
||||
393
tools/citrineos-core-main/apps/operator-ui/README.MD
Normal file
@@ -0,0 +1,393 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
|
||||

|
||||

|
||||
|
||||
<div align="center">
|
||||
<img
|
||||
src="public/OCPP_201_Logo_core_and_advanced_security.png"
|
||||
alt="CitrineOS Certification Logo"
|
||||
width="200"
|
||||
height="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
# Welcome to CitrineOS Operator UI
|
||||
|
||||
**CitrineOS Operator UI** is the web-based operator interface for **CitrineOS**, an open-source, modular backend
|
||||
platform for managing Electric Vehicle (EV) charging infrastructure.
|
||||
|
||||
This repository contains the **Operator-facing User Interface** and related tooling.
|
||||
It is designed to work together with **CitrineOS Core**, which provides the charging station management logic,
|
||||
OCPP message handling, persistence layer, and GraphQL APIs.
|
||||
|
||||
> ⚠️ **Important:**
|
||||
> The Operator UI **does not run standalone**.
|
||||
> A running instance of **CitrineOS Core** (including Hasura GraphQL Engine) is required.
|
||||
|
||||
This app is one workspace member of the `citrineos-core` pnpm monorepo. For repository-wide setup and for running the
|
||||
full stack (server + UI + Hasura) together, see the [root README](../../README.md).
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Architecture & Data Flow](#architecture--data-flow)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Running with Docker](#running-with-docker-recommended)
|
||||
- [Running with pnpm (Local Development)](#running-with-pnpm-local-development)
|
||||
- [Working with `@citrineos/base`](#working-with-citrineosbase)
|
||||
- [Build & Development Workflow](#build--development-workflow)
|
||||
- [Running the Application](#running-the-application)
|
||||
- [Bringing a Charging Station Online (End-to-End)](#bringing-a-charging-station-online-end-to-end)
|
||||
- [Create a Location](#create-a-location)
|
||||
- [Add a Charging Station (Charge Point)](#add-a-charging-station-charge-point)
|
||||
- [Add an EVSE (Electric Vehicle Supply Equipment)](#add-an-evse-electric-vehicle-supply-equipment)
|
||||
- [Add a Connector](#add-a-connector)
|
||||
- [Add an Authorization (ID Token)](#add-an-authorization-id-token)
|
||||
- [Start the Charging Station (EVerest)](#start-the-charging-station-everest)
|
||||
- [Usage](#usage)
|
||||
- [Hasura Authentication](#hasura-authentication)
|
||||
- [Related Documentation](#related-documentation)
|
||||
- [Contributing](#contributing)
|
||||
- [Licensing](#licensing)
|
||||
- [Support & Contact](#support--contact)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The CitrineOS Operator UI provides operators with:
|
||||
|
||||
- Visibility into charging stations, connectors, sessions, and transactions
|
||||
- Operational controls via CitrineOS Core Message APIs
|
||||
- Data access through **Hasura GraphQL Engine**
|
||||
- A modern React-based UI built with **Refine**
|
||||
|
||||
The UI consumes:
|
||||
|
||||
- **Hasura GraphQL APIs** for data access
|
||||
- **CitrineOS Core Message APIs** for sending commands to charging stations
|
||||
- **CitrineOS Core Data APIs** for managing entities
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
||||
```text
|
||||
+-------------------+ GraphQL +----------------------+
|
||||
| | <----------------> | |
|
||||
| Operator UI | | Hasura GraphQL |
|
||||
| | | Engine |
|
||||
| | | |
|
||||
+-------------------+ +----------+-----------+
|
||||
|
|
||||
| SQL
|
||||
|
|
||||
+---------v-----------+
|
||||
| |
|
||||
| PostgreSQL |
|
||||
| |
|
||||
+---------------------+
|
||||
|
||||
+-------------------+ REST +--------------------------+
|
||||
| Operator UI | <--------------> | CitrineOS Core |
|
||||
| | | (OCPP 1.6 & 2.0.1) |
|
||||
+-------------------+ +--------------------------+
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting, ensure the following tools and services are installed and running.
|
||||
|
||||
- **Node.js** (v24.16.0 or higher)
|
||||
[Link](https://nodejs.org)
|
||||
|
||||
- **pnpm**
|
||||
[Link](https://pnpm.io/installation) — the workspace's package manager
|
||||
|
||||
- **Docker & Docker Compose** (recommended)
|
||||
[Link](https://www.docker.com/products/docker-desktop/)
|
||||
|
||||
The following services must be available before starting the application:
|
||||
|
||||
- **CitrineOS Core**
|
||||
Backend service responsible for OCPP 1.6 and OCPP 2.0.1 handling, charging station management, and APIs.
|
||||
|
||||
- **Hasura GraphQL Engine**
|
||||
GraphQL layer used for data access and management.
|
||||
|
||||
References:
|
||||
|
||||
- **CitrineOS Core Repository**
|
||||
[Documentation](https://github.com/citrineos/citrineos-core)
|
||||
|
||||
- **Hasura Documentation**
|
||||
[Documentation](https://hasura.io/docs/2.0/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Running with Docker (Recommended)
|
||||
|
||||
If **CitrineOS Core** is already running via Docker, the Operator UI can be started with a single command:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
- Build the Operator UI container
|
||||
- Start the UI connected to the configured Hasura and Core service
|
||||
|
||||
### Running with pnpm (Local Development)
|
||||
|
||||
The Operator UI lives inside the `citrineos-core` **pnpm workspace** (under `apps/operator-ui`). Install all
|
||||
workspace dependencies once from the repository root, then start the dev server:
|
||||
|
||||
```bash
|
||||
# from the repository root
|
||||
pnpm install
|
||||
|
||||
# then, from apps/operator-ui
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
This starts the development server (Next.js + Refine) with hot reload.
|
||||
|
||||
### Working with `@citrineos/base`
|
||||
|
||||
The Operator UI depends on `@citrineos/base` via a `workspace:*` dependency, so pnpm resolves it from the local
|
||||
`packages/base` automatically — no `npm link` step is required. Just build the workspace packages so the compiled
|
||||
output is available:
|
||||
|
||||
```bash
|
||||
# from the repository root
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Build & Development Workflow
|
||||
|
||||
The Operator UI is a member of the `citrineos-core` **pnpm workspace**.
|
||||
|
||||
### Typical Workflow
|
||||
|
||||
1. Install and build the workspace from the repository root:
|
||||
|
||||
```bash
|
||||
# from the repository root
|
||||
pnpm install
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
2. Start the dev server:
|
||||
|
||||
```bash
|
||||
# from apps/operator-ui
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Development Mode
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
- Runs the app with hot reload
|
||||
- Default URL: http://localhost:3000
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm run start
|
||||
```
|
||||
|
||||
- Production URL: http://localhost:3000
|
||||
|
||||
## Bringing a Charging Station Online (End-to-End)
|
||||
|
||||
This section describes the **minimum operational steps** required to bring a charging station online using:
|
||||
|
||||
- **CitrineOS Operator UI**
|
||||
- **CitrineOS Core**
|
||||
- **EVerest simulator**
|
||||
|
||||
These steps are required even for local development and testing.
|
||||
|
||||
---
|
||||
|
||||
### Create a Location
|
||||
|
||||
In CitrineOS, **all charging stations must belong to a Location**.
|
||||
|
||||
1. Open **CitrineOS Operator UI**:
|
||||
2. Navigate to:
|
||||
|
||||
```pgsql
|
||||
Locations → Create Location
|
||||
```
|
||||
|
||||
3. Fill in the required metadata (name, address, country, etc.)
|
||||
4. Save the Location
|
||||
|
||||
> A Location represents a physical site such as a parking garage, depot, or charging hub.
|
||||
|
||||
---
|
||||
|
||||
### Add a Charging Station (Charge Point)
|
||||
|
||||
To add a new charging station in CitrineOS Operator UI:
|
||||
|
||||
1. Navigate to:
|
||||
|
||||
```sql
|
||||
Charging Stations → Add Charging Station
|
||||
```
|
||||
|
||||
2. Select the previously created **Location**.
|
||||
|
||||
3. Use the default **Charge Point ID**:
|
||||
|
||||
```nginx
|
||||
cp001
|
||||
```
|
||||
|
||||
> ⚠️ **Important:**
|
||||
> The Charge Point ID must exactly match the ID used by your physical charging station or simulator.
|
||||
> By default, **EVerest** uses `cp001`.
|
||||
|
||||
4. Click **Save** to create the charging station.
|
||||
|
||||
---
|
||||
|
||||
### Add an EVSE (Electric Vehicle Supply Equipment)
|
||||
|
||||
Each Charging Station must contain at least one EVSE.
|
||||
|
||||
1. Open the Charging Station you just created.
|
||||
2. Navigate to:
|
||||
|
||||
```sql
|
||||
EVSEs → Add EVSE
|
||||
```
|
||||
|
||||
3. Use a numeric **EVSE ID** (example: `1`).
|
||||
4. Click **Save**.
|
||||
|
||||
> EVSEs represent physical charge points within a station (e.g., left/right ports).
|
||||
|
||||
---
|
||||
|
||||
### Add a Connector
|
||||
|
||||
Each EVSE must have at least one Connector.
|
||||
|
||||
1. Open the EVSE.
|
||||
2. Navigate to:
|
||||
|
||||
```sql
|
||||
Connectors → Add Connector
|
||||
```
|
||||
|
||||
3. Choose:
|
||||
- **Connector ID** (example: `1`)
|
||||
- **Connector type** (Type2, CCS, etc.)
|
||||
4. Click **Save**.
|
||||
|
||||
> Without a Connector, charging sessions cannot be started.
|
||||
|
||||
---
|
||||
|
||||
### Add an Authorization (ID Token)
|
||||
|
||||
To allow charging sessions, an ID Token must be authorized.
|
||||
|
||||
1. Navigate to:
|
||||
|
||||
```sql
|
||||
Authorizations → Add Authorization
|
||||
```
|
||||
|
||||
2. Create an **ID Token** (RFID, app token, etc.)
|
||||
3. Mark it as **Accepted**
|
||||
4. Click **Save**
|
||||
|
||||
> ⚠️ Note: This token will be used by EVerest to start charging.
|
||||
> The EVerest simulator uses the **default RFID token** `DEADBEEF` **(ISO14443)**.
|
||||
|
||||
---
|
||||
|
||||
### Start the Charging Station (EVerest)
|
||||
|
||||
Now that the backend configuration is complete, start the EVerest simulator:
|
||||
|
||||
```bash
|
||||
cd citrineos-core/apps/Server
|
||||
pnpm run start-everest
|
||||
```
|
||||
|
||||
This will:
|
||||
**In Operator UI**
|
||||
|
||||
- Navigate to **Charging Stations**
|
||||
- Status should show:
|
||||
|
||||
```nginx
|
||||
Online
|
||||
```
|
||||
|
||||
You can now track OCPP logs for the charging station.
|
||||
|
||||
## Usage
|
||||
|
||||
Once running, the Operator UI allows you to:
|
||||
|
||||
- View and manage charging stations and connectors
|
||||
- Monitor transactions and sessions
|
||||
- Trigger charging station commands (via CitrineOS Core)
|
||||
- Query and manage data through Hasura-backed GraphQL APIs
|
||||
|
||||
## Hasura Authentication
|
||||
|
||||
There are two ways to authenticate to Hasura:
|
||||
|
||||
1. If you have \*_uncommented_ the Docker environment variable `HASURA_GRAPHQL_ADMIN_SECRET` in `citrineos-core`: 2. Ensure your `.env.local` has the `HASURA_ADMIN_SECRET` uncommented out 3. Please note that it is not recommended to use a Hasura Admin Secret in production.
|
||||
2. If you have commented the Docker environment variable `HASURA_GRAPHQL_ADMIN_SECRET` in `citrineos-core`: 5. Ensure your `.env.local` has the `HASURA_ADMIN_SECRET` commented out, as the auth will be handled by your auth provider automatically.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **CitrineOS Core**
|
||||
- [Docs](https://github.com/citrineos/citrineos-core)
|
||||
- **CitrineOS Project Docs**
|
||||
- [Docs](https://citrineos.github.io)
|
||||
- **Refine**
|
||||
- [Docs](https://refine.dev/docs)
|
||||
- **Hasura GraphQL Engine**
|
||||
- [Docs](https://hasura.io/docs/2.0/index/)
|
||||
- **Postgres Data Connector**
|
||||
- [Docs](https://hasura.io/docs/2.0/databases/postgres/index/)
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions from the community. If you would like to contribute to CitrineOS, please follow
|
||||
our [contribution guidelines](https://github.com/citrineos/citrineos/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## Licensing
|
||||
|
||||
CitrineOS and its subprojects are licensed under the Apache License, Version 2.0.
|
||||
|
||||
## Support and Contact
|
||||
|
||||
If you need help or want to report an issue:
|
||||
|
||||
- Open an issue on GitHub
|
||||
- Reach out via the CitrineOS community channels
|
||||
@@ -0,0 +1,195 @@
|
||||
[
|
||||
{ "code": "AF", "name": "Afghanistan" },
|
||||
{ "code": "AL", "name": "Albania" },
|
||||
{ "code": "DZ", "name": "Algeria" },
|
||||
{ "code": "AD", "name": "Andorra" },
|
||||
{ "code": "AO", "name": "Angola" },
|
||||
{ "code": "AG", "name": "Antigua and Barbuda" },
|
||||
{ "code": "AR", "name": "Argentina" },
|
||||
{ "code": "AM", "name": "Armenia" },
|
||||
{ "code": "AU", "name": "Australia" },
|
||||
{ "code": "AT", "name": "Austria" },
|
||||
{ "code": "AZ", "name": "Azerbaijan" },
|
||||
{ "code": "BS", "name": "Bahamas" },
|
||||
{ "code": "BH", "name": "Bahrain" },
|
||||
{ "code": "BD", "name": "Bangladesh" },
|
||||
{ "code": "BB", "name": "Barbados" },
|
||||
{ "code": "BY", "name": "Belarus" },
|
||||
{ "code": "BE", "name": "Belgium" },
|
||||
{ "code": "BZ", "name": "Belize" },
|
||||
{ "code": "BJ", "name": "Benin" },
|
||||
{ "code": "BT", "name": "Bhutan" },
|
||||
{ "code": "BO", "name": "Bolivia" },
|
||||
{ "code": "BA", "name": "Bosnia and Herzegovina" },
|
||||
{ "code": "BW", "name": "Botswana" },
|
||||
{ "code": "BR", "name": "Brazil" },
|
||||
{ "code": "BN", "name": "Brunei" },
|
||||
{ "code": "BG", "name": "Bulgaria" },
|
||||
{ "code": "BF", "name": "Burkina Faso" },
|
||||
{ "code": "BI", "name": "Burundi" },
|
||||
{ "code": "CV", "name": "Cabo Verde" },
|
||||
{ "code": "KH", "name": "Cambodia" },
|
||||
{ "code": "CM", "name": "Cameroon" },
|
||||
{ "code": "CA", "name": "Canada" },
|
||||
{ "code": "CF", "name": "Central African Republic" },
|
||||
{ "code": "TD", "name": "Chad" },
|
||||
{ "code": "CL", "name": "Chile" },
|
||||
{ "code": "CN", "name": "China" },
|
||||
{ "code": "CO", "name": "Colombia" },
|
||||
{ "code": "KM", "name": "Comoros" },
|
||||
{ "code": "CG", "name": "Congo" },
|
||||
{ "code": "CD", "name": "Congo, Democratic Republic of the" },
|
||||
{ "code": "CR", "name": "Costa Rica" },
|
||||
{ "code": "HR", "name": "Croatia" },
|
||||
{ "code": "CU", "name": "Cuba" },
|
||||
{ "code": "CY", "name": "Cyprus" },
|
||||
{ "code": "CZ", "name": "Czechia" },
|
||||
{ "code": "DK", "name": "Denmark" },
|
||||
{ "code": "DJ", "name": "Djibouti" },
|
||||
{ "code": "DM", "name": "Dominica" },
|
||||
{ "code": "DO", "name": "Dominican Republic" },
|
||||
{ "code": "EC", "name": "Ecuador" },
|
||||
{ "code": "EG", "name": "Egypt" },
|
||||
{ "code": "SV", "name": "El Salvador" },
|
||||
{ "code": "GQ", "name": "Equatorial Guinea" },
|
||||
{ "code": "ER", "name": "Eritrea" },
|
||||
{ "code": "EE", "name": "Estonia" },
|
||||
{ "code": "SZ", "name": "Eswatini" },
|
||||
{ "code": "ET", "name": "Ethiopia" },
|
||||
{ "code": "FJ", "name": "Fiji" },
|
||||
{ "code": "FI", "name": "Finland" },
|
||||
{ "code": "FR", "name": "France" },
|
||||
{ "code": "GA", "name": "Gabon" },
|
||||
{ "code": "GM", "name": "Gambia" },
|
||||
{ "code": "GE", "name": "Georgia" },
|
||||
{ "code": "DE", "name": "Germany" },
|
||||
{ "code": "GH", "name": "Ghana" },
|
||||
{ "code": "GR", "name": "Greece" },
|
||||
{ "code": "GD", "name": "Grenada" },
|
||||
{ "code": "GT", "name": "Guatemala" },
|
||||
{ "code": "GN", "name": "Guinea" },
|
||||
{ "code": "GW", "name": "Guinea-Bissau" },
|
||||
{ "code": "GY", "name": "Guyana" },
|
||||
{ "code": "HT", "name": "Haiti" },
|
||||
{ "code": "HN", "name": "Honduras" },
|
||||
{ "code": "HU", "name": "Hungary" },
|
||||
{ "code": "IS", "name": "Iceland" },
|
||||
{ "code": "IN", "name": "India" },
|
||||
{ "code": "ID", "name": "Indonesia" },
|
||||
{ "code": "IR", "name": "Iran" },
|
||||
{ "code": "IQ", "name": "Iraq" },
|
||||
{ "code": "IE", "name": "Ireland" },
|
||||
{ "code": "IL", "name": "Israel" },
|
||||
{ "code": "IT", "name": "Italy" },
|
||||
{ "code": "JM", "name": "Jamaica" },
|
||||
{ "code": "JP", "name": "Japan" },
|
||||
{ "code": "JO", "name": "Jordan" },
|
||||
{ "code": "KZ", "name": "Kazakhstan" },
|
||||
{ "code": "KE", "name": "Kenya" },
|
||||
{ "code": "KI", "name": "Kiribati" },
|
||||
{ "code": "KP", "name": "Korea, North" },
|
||||
{ "code": "KR", "name": "Korea, South" },
|
||||
{ "code": "KW", "name": "Kuwait" },
|
||||
{ "code": "KG", "name": "Kyrgyzstan" },
|
||||
{ "code": "LA", "name": "Laos" },
|
||||
{ "code": "LV", "name": "Latvia" },
|
||||
{ "code": "LB", "name": "Lebanon" },
|
||||
{ "code": "LS", "name": "Lesotho" },
|
||||
{ "code": "LR", "name": "Liberia" },
|
||||
{ "code": "LY", "name": "Libya" },
|
||||
{ "code": "LI", "name": "Liechtenstein" },
|
||||
{ "code": "LT", "name": "Lithuania" },
|
||||
{ "code": "LU", "name": "Luxembourg" },
|
||||
{ "code": "MG", "name": "Madagascar" },
|
||||
{ "code": "MW", "name": "Malawi" },
|
||||
{ "code": "MY", "name": "Malaysia" },
|
||||
{ "code": "MV", "name": "Maldives" },
|
||||
{ "code": "ML", "name": "Mali" },
|
||||
{ "code": "MT", "name": "Malta" },
|
||||
{ "code": "MH", "name": "Marshall Islands" },
|
||||
{ "code": "MR", "name": "Mauritania" },
|
||||
{ "code": "MU", "name": "Mauritius" },
|
||||
{ "code": "MX", "name": "Mexico" },
|
||||
{ "code": "FM", "name": "Micronesia" },
|
||||
{ "code": "MD", "name": "Moldova" },
|
||||
{ "code": "MC", "name": "Monaco" },
|
||||
{ "code": "MN", "name": "Mongolia" },
|
||||
{ "code": "ME", "name": "Montenegro" },
|
||||
{ "code": "MA", "name": "Morocco" },
|
||||
{ "code": "MZ", "name": "Mozambique" },
|
||||
{ "code": "MM", "name": "Myanmar" },
|
||||
{ "code": "NA", "name": "Namibia" },
|
||||
{ "code": "NR", "name": "Nauru" },
|
||||
{ "code": "NP", "name": "Nepal" },
|
||||
{ "code": "NL", "name": "Netherlands" },
|
||||
{ "code": "NZ", "name": "New Zealand" },
|
||||
{ "code": "NI", "name": "Nicaragua" },
|
||||
{ "code": "NE", "name": "Niger" },
|
||||
{ "code": "NG", "name": "Nigeria" },
|
||||
{ "code": "MK", "name": "North Macedonia" },
|
||||
{ "code": "NO", "name": "Norway" },
|
||||
{ "code": "OM", "name": "Oman" },
|
||||
{ "code": "PK", "name": "Pakistan" },
|
||||
{ "code": "PW", "name": "Palau" },
|
||||
{ "code": "PA", "name": "Panama" },
|
||||
{ "code": "PG", "name": "Papua New Guinea" },
|
||||
{ "code": "PY", "name": "Paraguay" },
|
||||
{ "code": "PE", "name": "Peru" },
|
||||
{ "code": "PH", "name": "Philippines" },
|
||||
{ "code": "PL", "name": "Poland" },
|
||||
{ "code": "PT", "name": "Portugal" },
|
||||
{ "code": "QA", "name": "Qatar" },
|
||||
{ "code": "RO", "name": "Romania" },
|
||||
{ "code": "RU", "name": "Russia" },
|
||||
{ "code": "RW", "name": "Rwanda" },
|
||||
{ "code": "KN", "name": "Saint Kitts and Nevis" },
|
||||
{ "code": "LC", "name": "Saint Lucia" },
|
||||
{ "code": "VC", "name": "Saint Vincent and the Grenadines" },
|
||||
{ "code": "WS", "name": "Samoa" },
|
||||
{ "code": "SM", "name": "San Marino" },
|
||||
{ "code": "ST", "name": "Sao Tome and Principe" },
|
||||
{ "code": "SA", "name": "Saudi Arabia" },
|
||||
{ "code": "SN", "name": "Senegal" },
|
||||
{ "code": "RS", "name": "Serbia" },
|
||||
{ "code": "SC", "name": "Seychelles" },
|
||||
{ "code": "SL", "name": "Sierra Leone" },
|
||||
{ "code": "SG", "name": "Singapore" },
|
||||
{ "code": "SK", "name": "Slovakia" },
|
||||
{ "code": "SI", "name": "Slovenia" },
|
||||
{ "code": "SB", "name": "Solomon Islands" },
|
||||
{ "code": "SO", "name": "Somalia" },
|
||||
{ "code": "ZA", "name": "South Africa" },
|
||||
{ "code": "SS", "name": "South Sudan" },
|
||||
{ "code": "ES", "name": "Spain" },
|
||||
{ "code": "LK", "name": "Sri Lanka" },
|
||||
{ "code": "SD", "name": "Sudan" },
|
||||
{ "code": "SR", "name": "Suriname" },
|
||||
{ "code": "SE", "name": "Sweden" },
|
||||
{ "code": "CH", "name": "Switzerland" },
|
||||
{ "code": "SY", "name": "Syria" },
|
||||
{ "code": "TW", "name": "Taiwan" },
|
||||
{ "code": "TJ", "name": "Tajikistan" },
|
||||
{ "code": "TZ", "name": "Tanzania" },
|
||||
{ "code": "TH", "name": "Thailand" },
|
||||
{ "code": "TL", "name": "Timor-Leste" },
|
||||
{ "code": "TG", "name": "Togo" },
|
||||
{ "code": "TO", "name": "Tonga" },
|
||||
{ "code": "TT", "name": "Trinidad and Tobago" },
|
||||
{ "code": "TN", "name": "Tunisia" },
|
||||
{ "code": "TR", "name": "Turkey" },
|
||||
{ "code": "TM", "name": "Turkmenistan" },
|
||||
{ "code": "TV", "name": "Tuvalu" },
|
||||
{ "code": "UG", "name": "Uganda" },
|
||||
{ "code": "UA", "name": "Ukraine" },
|
||||
{ "code": "AE", "name": "United Arab Emirates" },
|
||||
{ "code": "GB", "name": "United Kingdom" },
|
||||
{ "code": "US", "name": "United States" },
|
||||
{ "code": "UY", "name": "Uruguay" },
|
||||
{ "code": "UZ", "name": "Uzbekistan" },
|
||||
{ "code": "VU", "name": "Vanuatu" },
|
||||
{ "code": "VE", "name": "Venezuela" },
|
||||
{ "code": "VN", "name": "Vietnam" },
|
||||
{ "code": "YE", "name": "Yemen" },
|
||||
{ "code": "ZM", "name": "Zambia" },
|
||||
{ "code": "ZW", "name": "Zimbabwe" }
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"US": [
|
||||
{ "name": "Alabama", "shortName": "AL" },
|
||||
{ "name": "Alaska", "shortName": "AK" },
|
||||
{ "name": "Arizona", "shortName": "AZ" },
|
||||
{ "name": "Arkansas", "shortName": "AR" },
|
||||
{ "name": "California", "shortName": "CA" },
|
||||
{ "name": "Colorado", "shortName": "CO" },
|
||||
{ "name": "Connecticut", "shortName": "CT" },
|
||||
{ "name": "Delaware", "shortName": "DE" },
|
||||
{ "name": "Florida", "shortName": "FL" },
|
||||
{ "name": "Georgia", "shortName": "GA" },
|
||||
{ "name": "Hawaii", "shortName": "HI" },
|
||||
{ "name": "Idaho", "shortName": "ID" },
|
||||
{ "name": "Illinois", "shortName": "IL" },
|
||||
{ "name": "Indiana", "shortName": "IN" },
|
||||
{ "name": "Iowa", "shortName": "IA" },
|
||||
{ "name": "Kansas", "shortName": "KS" },
|
||||
{ "name": "Kentucky", "shortName": "KY" },
|
||||
{ "name": "Louisiana", "shortName": "LA" },
|
||||
{ "name": "Maine", "shortName": "ME" },
|
||||
{ "name": "Maryland", "shortName": "MD" },
|
||||
{ "name": "Massachusetts", "shortName": "MA" },
|
||||
{ "name": "Michigan", "shortName": "MI" },
|
||||
{ "name": "Minnesota", "shortName": "MN" },
|
||||
{ "name": "Mississippi", "shortName": "MS" },
|
||||
{ "name": "Missouri", "shortName": "MO" },
|
||||
{ "name": "Montana", "shortName": "MT" },
|
||||
{ "name": "Nebraska", "shortName": "NE" },
|
||||
{ "name": "Nevada", "shortName": "NV" },
|
||||
{ "name": "New Hampshire", "shortName": "NH" },
|
||||
{ "name": "New Jersey", "shortName": "NJ" },
|
||||
{ "name": "New Mexico", "shortName": "NM" },
|
||||
{ "name": "New York", "shortName": "NY" },
|
||||
{ "name": "North Carolina", "shortName": "NC" },
|
||||
{ "name": "North Dakota", "shortName": "ND" },
|
||||
{ "name": "Ohio", "shortName": "OH" },
|
||||
{ "name": "Oklahoma", "shortName": "OK" },
|
||||
{ "name": "Oregon", "shortName": "OR" },
|
||||
{ "name": "Pennsylvania", "shortName": "PA" },
|
||||
{ "name": "Rhode Island", "shortName": "RI" },
|
||||
{ "name": "South Carolina", "shortName": "SC" },
|
||||
{ "name": "South Dakota", "shortName": "SD" },
|
||||
{ "name": "Tennessee", "shortName": "TN" },
|
||||
{ "name": "Texas", "shortName": "TX" },
|
||||
{ "name": "Utah", "shortName": "UT" },
|
||||
{ "name": "Vermont", "shortName": "VT" },
|
||||
{ "name": "Virginia", "shortName": "VA" },
|
||||
{ "name": "Washington", "shortName": "WA" },
|
||||
{ "name": "West Virginia", "shortName": "WV" },
|
||||
{ "name": "Wisconsin", "shortName": "WI" },
|
||||
{ "name": "Wyoming", "shortName": "WY" }
|
||||
],
|
||||
"CA": [
|
||||
{ "name": "Alberta", "shortName": "AB" },
|
||||
{ "name": "British Columbia", "shortName": "BC" },
|
||||
{ "name": "Manitoba", "shortName": "MB" },
|
||||
{ "name": "New Brunswick", "shortName": "NB" },
|
||||
{ "name": "Newfoundland and Labrador", "shortName": "NL" },
|
||||
{ "name": "Northwest Territories", "shortName": "NT" },
|
||||
{ "name": "Nova Scotia", "shortName": "NS" },
|
||||
{ "name": "Nunavut", "shortName": "NU" },
|
||||
{ "name": "Ontario", "shortName": "ON" },
|
||||
{ "name": "Prince Edward Island", "shortName": "PE" },
|
||||
{ "name": "Quebec", "shortName": "QC" },
|
||||
{ "name": "Saskatchewan", "shortName": "SK" },
|
||||
{ "name": "Yukon", "shortName": "YT" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"US": {
|
||||
"administrativeAreaLabel": "State",
|
||||
"postalCodeLabel": "ZIP Code",
|
||||
"postalCodePattern": "^\\d{5}(-\\d{4})?$"
|
||||
},
|
||||
"CA": {
|
||||
"administrativeAreaLabel": "Province",
|
||||
"postalCodeLabel": "Postal Code",
|
||||
"postalCodePattern": "^[A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d$"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/**
|
||||
* Type definitions and schemas for country configuration JSON files
|
||||
*/
|
||||
|
||||
/**
|
||||
* Schema for all-countries.json
|
||||
* An array of country objects with code and name
|
||||
*/
|
||||
export type AllCountriesSchema = Array<{
|
||||
code: string;
|
||||
name: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Schema for country-configs.json
|
||||
* A record of country codes to their specific configurations
|
||||
*/
|
||||
export type CountryConfigsSchema = Record<
|
||||
string,
|
||||
{
|
||||
administrativeAreaLabel?: string;
|
||||
postalCodeLabel?: string;
|
||||
postalCodePattern?: string;
|
||||
postalCodeRequired?: boolean;
|
||||
usesAdministrativeAreas?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Validates all countries JSON structure
|
||||
*/
|
||||
export function validateAllCountries(data: unknown): data is AllCountriesSchema {
|
||||
return (
|
||||
Array.isArray(data) &&
|
||||
data.every(
|
||||
(item) =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'code' in item &&
|
||||
'name' in item &&
|
||||
typeof item.code === 'string' &&
|
||||
typeof item.name === 'string',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates country configs JSON structure
|
||||
*/
|
||||
export function validateCountryConfigs(data: unknown): data is CountryConfigsSchema {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(data).every(
|
||||
(config) =>
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
(config.administrativeAreaLabel === undefined ||
|
||||
typeof config.administrativeAreaLabel === 'string') &&
|
||||
(config.postalCodeLabel === undefined || typeof config.postalCodeLabel === 'string') &&
|
||||
(config.postalCodePattern === undefined || typeof config.postalCodePattern === 'string') &&
|
||||
(config.postalCodeRequired === undefined || typeof config.postalCodeRequired === 'boolean') &&
|
||||
(config.usesAdministrativeAreas === undefined ||
|
||||
typeof config.usesAdministrativeAreas === 'boolean'),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
services:
|
||||
citrine-ui:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: apps/operator-ui/Dockerfile
|
||||
ports:
|
||||
- 3000:3000
|
||||
69
tools/citrineos-core-main/apps/operator-ui/eslint.config.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
import eslintPluginReact from 'eslint-plugin-react';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import { sharedConfigs } from '../../eslint.config.base.js';
|
||||
|
||||
const compat = new FlatCompat();
|
||||
const nextConfigs = compat.extends('next/core-web-vitals').map((config) => {
|
||||
const { useEslintrc, extensions, ...rest } = config;
|
||||
return rest;
|
||||
});
|
||||
|
||||
export default tseslint.config(
|
||||
...sharedConfigs,
|
||||
...nextConfigs,
|
||||
{
|
||||
plugins: {
|
||||
react: eslintPluginReact,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unsafe-function-type': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'cypress/no-unnecessary-waiting': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['tests/e2e/**/*.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
// Playwright fixtures destructure dependencies; an empty pattern
|
||||
// is the idiomatic way to declare a fixture with no deps.
|
||||
'no-empty-pattern': 'off',
|
||||
// Playwright's fixture API receives a `use` parameter; the React
|
||||
// hooks linter mis-identifies it as a hook call. Off in tests/e2e only.
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: "CallExpression[callee.property.name='waitForTimeout']",
|
||||
message:
|
||||
'Use expect.poll, expect(locator).toBeVisible(), or locator.waitFor instead of waitForTimeout (Phase 0 plan §11).',
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.object.name='setTimeout']",
|
||||
message: 'Sleep-based waits are forbidden. Use expect.poll or locator.waitFor.',
|
||||
},
|
||||
],
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['playwright', 'playwright-core'],
|
||||
message:
|
||||
'Import Page, Locator, BrowserContext, APIRequestContext from @playwright/test, not playwright.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['**/dist/**', '**/node_modules/**', '**/.next/**', '**/*.d.ts'],
|
||||
},
|
||||
);
|
||||
50
tools/citrineos-core-main/apps/operator-ui/next.config.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const withNextIntl = createNextIntlPlugin(resolve(__dirname, 'src/lib/i18n/request.ts'));
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
// Trace from the monorepo root so the standalone output bundles workspace
|
||||
// dependencies (@citrineos/base) correctly.
|
||||
outputFileTracingRoot: resolve(__dirname, '../..'),
|
||||
devIndicators: {
|
||||
position: 'bottom-right',
|
||||
},
|
||||
eslint: {
|
||||
// Next 15's built-in lint runner does not currently load the flat
|
||||
// eslint.config.mjs + typescript-eslint parser correctly here, causing
|
||||
// spurious "import is reserved" errors. Run lint via `pnpm lint` instead.
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'storage.googleapis.com',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
webpack: (config) => {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'class-transformer/types/storage': resolve(
|
||||
__dirname,
|
||||
'node_modules/class-transformer/cjs/storage.js',
|
||||
),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
131
tools/citrineos-core-main/apps/operator-ui/package.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"name": "@citrineos/operator-ui",
|
||||
"version": "1.3.0-alpha2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean-tsbuildinfo": "find . -name tsconfig.tsbuildinfo -not -path '*/node_modules/*' -exec rm -f {} +",
|
||||
"clean-dist": "find . -type d -name .next -not -path '*/node_modules/*' -exec rm -rf {} +",
|
||||
"clean": "pnpm run clean-dist && pnpm run clean-tsbuildinfo",
|
||||
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev",
|
||||
"build": "refine build",
|
||||
"start": "refine start",
|
||||
"prettier": "prettier --write .",
|
||||
"lint": "eslint ./",
|
||||
"lint-fix": "pnpm run prettier && eslint --fix ./",
|
||||
"refine": "refine",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"test:e2e:codegen": "playwright codegen http://localhost:3000",
|
||||
"test:e2e:report": "playwright show-report",
|
||||
"playwright:install": "playwright install chromium"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1057.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1057.0",
|
||||
"@citrineos/base": "workspace:*",
|
||||
"@ferdiunal/refine-shadcn": "1.6.1",
|
||||
"@google-cloud/storage": "7.18.0",
|
||||
"@googlemaps/markerclusterer": "2.5.3",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "0.57.0",
|
||||
"@opentelemetry/resources": "^1.30.0",
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.41.1",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.12",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.7",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@reduxjs/toolkit": "2.9.0",
|
||||
"@refinedev/cli": "2.16.48",
|
||||
"@refinedev/core": "5.0.0",
|
||||
"@refinedev/devtools": "2.0.1",
|
||||
"@refinedev/hasura": "7.0.0",
|
||||
"@refinedev/kbar": "2.0.0",
|
||||
"@refinedev/nextjs-router": "7.0.0",
|
||||
"@refinedev/react-hook-form": "5.0.2",
|
||||
"@refinedev/react-table": "6.0.1",
|
||||
"@refinedev/ui-types": "2.0.1",
|
||||
"@tanstack/react-query": "5.90.5",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@vis.gl/react-google-maps": "1.5.1",
|
||||
"axios": "1.12.2",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.14.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"framer-motion": "12.23.22",
|
||||
"geojson": "0.5.0",
|
||||
"graphql": "15.6.1",
|
||||
"graphql-request": "5.2.0",
|
||||
"graphql-tag": "2.12.6",
|
||||
"js-cookie": "3.0.5",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"lodash.isequal": "4.5.0",
|
||||
"lucide-react": "0.545.0",
|
||||
"next": "15.2.8",
|
||||
"next-auth": "4.24.13",
|
||||
"next-intl": "4.4.0",
|
||||
"next-themes": "0.4.6",
|
||||
"nuqs": "2.8.8",
|
||||
"postcss": "8.5.3",
|
||||
"react": "19.1.4",
|
||||
"react-day-picker": "9.11.1",
|
||||
"react-dom": "19.1.4",
|
||||
"react-hook-form": "7.65.0",
|
||||
"react-redux": "9.2.0",
|
||||
"react-syntax-highlighter": "15.6.6",
|
||||
"recharts": "3.5.1",
|
||||
"redux-persist": "6.0.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"sass": "1.93.2",
|
||||
"shadcn": "2.4.1",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"zod": "4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@tailwindcss/postcss": "4",
|
||||
"@types/geojson": "7946.0.16",
|
||||
"@types/js-cookie": "3.0.6",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/lodash.debounce": "4.0.9",
|
||||
"@types/lodash.isequal": "4.5.8",
|
||||
"@types/node": "20",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/react-dom": "19.1.4",
|
||||
"@types/react-syntax-highlighter": "15.5.13",
|
||||
"cross-env": "7.0.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-next": "15.0.3",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint-plugin-prettier": "catalog:",
|
||||
"eslint-plugin-react": "^7.37.0",
|
||||
"postcss": "8",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "4",
|
||||
"typescript": "^6.0.0",
|
||||
"typescript-eslint": "catalog:"
|
||||
},
|
||||
"refine": {
|
||||
"projectId": "EoX1xN-qak0Xy-elp4R9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
dotenv.config({ path: resolve(__dirname, '.env.test'), override: true });
|
||||
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||
const isCI = !!process.env.CI;
|
||||
|
||||
const reporters: NonNullable<Parameters<typeof defineConfig>[0]['reporter']> = [
|
||||
['html', { open: 'never', outputFolder: 'playwright-report' }],
|
||||
['list'],
|
||||
['junit', { outputFile: 'reports/junit.xml' }],
|
||||
];
|
||||
if (isCI) reporters.push(['github']);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e/specs',
|
||||
outputDir: './test-results',
|
||||
|
||||
fullyParallel: true,
|
||||
forbidOnly: isCI,
|
||||
retries: isCI ? 1 : 0,
|
||||
// workers=1 because the Next.js dev server serves all routes through a
|
||||
// single compiler instance; under workers > 1 the parametric harness
|
||||
// intermittently leaves Refine `useOne(ChargingStations_by_pk)` queries
|
||||
// hanging while the dev server is stuck in a re-compile loop. Sequential
|
||||
// route compilation eliminates the flake at the cost of wall-clock time.
|
||||
workers: 1,
|
||||
|
||||
// 150s default test timeout: expectLoaded budgets up to 90s for cold-route
|
||||
// Next.js compilation + the useOne(ChargingStation) query under heavy
|
||||
// concurrency; the rest of a typical spec needs ~60s.
|
||||
timeout: 150_000,
|
||||
|
||||
reporter: reporters,
|
||||
|
||||
globalSetup: './tests/e2e/auth/global-setup.ts',
|
||||
globalTeardown: './tests/e2e/auth/global-teardown.ts',
|
||||
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
|
||||
use: {
|
||||
baseURL,
|
||||
actionTimeout: 10_000,
|
||||
navigationTimeout: 30_000,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
locale: 'en-US',
|
||||
timezoneId: 'UTC',
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
testDir: './tests/e2e/auth',
|
||||
},
|
||||
{
|
||||
name: 'chromium-desktop',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
storageState: 'playwright/.auth/admin.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
// Skip everest-tagged specs on the per-PR lane; they only run on
|
||||
// the everest-serial project (workers=1) so EVerest's single mutable
|
||||
// OCPP session is never raced.
|
||||
grepInvert: /@everest/,
|
||||
},
|
||||
{
|
||||
// EVerest lane — workers=1, longer timeout, runs nightly.
|
||||
name: 'everest-serial',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
storageState: 'playwright/.auth/admin.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
workers: 1,
|
||||
timeout: 180_000,
|
||||
fullyParallel: false,
|
||||
grep: /@everest/,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 353 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg width="1440" height="834" viewBox="0 0 1440 834" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_f_268_1804)">
|
||||
<path d="M993.085 908.711C919.031 1133.52 734.23 1233.65 506.571 1158.66C278.911 1083.66 -209.974 791.896 -135.92 567.086C-61.8657 342.276 -59.7456 909.78 584.529 471.226C1149.14 102.24 1067.14 683.901 993.085 908.711Z" fill="url(#paint0_linear_268_1804)" fill-opacity="0.79"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_268_1804)">
|
||||
<path d="M1403.72 391.896C1358.24 571.341 1215.13 689.886 1084.06 656.673C952.995 623.459 827.294 618.207 872.767 438.762C918.239 259.316 967.561 235.571 1139.54 279.15C1270.6 312.364 1274.88 136.117 1403.72 391.896Z" fill="#A5B2FF" fill-opacity="0.5"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_268_1804" x="-393.479" y="101.584" width="1689.18" height="1331.68" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="125" result="effect1_foregroundBlur_268_1804"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_268_1804" x="613.275" y="0.543945" width="1040.44" height="911.706" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="125" result="effect1_foregroundBlur_268_1804"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_268_1804" x1="206.96" y1="660.118" x2="654.531" y2="348.777" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A5B2FF" stop-opacity="0.59"/>
|
||||
<stop offset="1" stop-color="#6E87FF" stop-opacity="0.52"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg width="1440" height="834" viewBox="0 0 1440 834" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_f_288_1832)">
|
||||
<path d="M993.085 908.711C919.031 1133.52 734.23 1233.65 506.571 1158.66C278.911 1083.66 -209.974 791.896 -135.92 567.086C-61.8657 342.276 -59.7456 909.78 584.529 471.226C1149.14 102.24 1067.14 683.901 993.085 908.711Z" fill="url(#paint0_linear_288_1832)"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_288_1832)">
|
||||
<path d="M1403.72 391.896C1358.24 571.341 1215.13 689.886 1084.06 656.673C952.995 623.459 827.294 618.207 872.767 438.762C918.239 259.316 967.561 235.571 1139.54 279.15C1270.6 312.364 1274.88 136.117 1403.72 391.896Z" fill="#F9B41E" fill-opacity="0.57"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_288_1832" x="-393.479" y="101.584" width="1689.18" height="1331.68" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="125" result="effect1_foregroundBlur_288_1832"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_288_1832" x="613.275" y="0.543945" width="1040.44" height="911.706" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="125" result="effect1_foregroundBlur_288_1832"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_288_1832" x1="206.96" y1="660.118" x2="654.531" y2="348.777" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A5B2FF" stop-opacity="0.59"/>
|
||||
<stop offset="1" stop-color="#6E87FF" stop-opacity="0.52"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Authorizations": {
|
||||
"Authorizations": "Authorizations",
|
||||
"authorization": "Authorization",
|
||||
"noDataFound": "No authorization found with id {id}."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"ChargingStations": {
|
||||
"ChargingStations": "Charging Stations",
|
||||
"chargingStation": "Charging Station",
|
||||
"use16StatusNotification0": "Use OCPP 1.6 Status Notifications with Connector Id 0",
|
||||
"toggleOnlineStatus": "Toggle Online Status",
|
||||
"toggleOnlineWarning": "You are about to mark this charging station as ",
|
||||
"toggleOnlineCaution": "Warning: This action may cause the UI to be out of sync with the actual station status. Only proceed if you are certain.",
|
||||
"commandsUnavailable": "Station offline - commands unavailable",
|
||||
"forceDisconnect": "Force Disconnect",
|
||||
"otherCommands": "Other Commands",
|
||||
"remoteStart": "Remote Start",
|
||||
"remoteStop": "Remote Stop",
|
||||
"reset": "Reset",
|
||||
"startTransaction": "Start Transaction",
|
||||
"stopTransaction": "Stop Transaction",
|
||||
"forceDisconnectMessage": "Are you sure you want to force disconnect this charging station",
|
||||
"forceDisconnectCaution": "This will immediately close the connection to the station.",
|
||||
"exportMessagesToCsvHeader": "Export OCPP Messages to CSV",
|
||||
"downloadAllMessagesToCsv": "You will download all OCPP messages for this charger.",
|
||||
"downloadMessagesToCsvWithFilters": "You will download OCPP messages for this charger with the following filters:",
|
||||
"liveLog": "Live log",
|
||||
"liveLogDisabledInactivity": "Live log disabled due to inactivity",
|
||||
"refreshMessages": "Refresh Messages",
|
||||
"noDataFound": "No charging station found with id {id}.",
|
||||
"connectionModal": {
|
||||
"welcomeTitle": "Welcome to CitrineOS Operator UI",
|
||||
"connectionTitle": "Charging Station Connection",
|
||||
"welcomeDescription": "Watch this quick introduction video to learn how to connect charging stations. You can access this information anytime by clicking the Help button.",
|
||||
"connectionDescription": "Use the following tenant-specific websocket URLs to connect to the server. The connection can be upgraded from No Authentication to Security Profile 3 one by one.",
|
||||
"showConnectionInfo": "Show Connection Info",
|
||||
"showHelpVideo": "Show Help Video",
|
||||
"gettingStartedVideo": "Getting Started Video",
|
||||
"videoNotSupported": "Your browser does not support the video tag.",
|
||||
"noVideoAvailable": "No video available",
|
||||
"videoDescription": "This video demonstrates how to connect charging stations to the CitrineOS platform.",
|
||||
"quickSteps": "Quick Steps",
|
||||
"step1": "Configure your charging station with the appropriate websocket URL",
|
||||
"step2": "Choose the security profile that matches your setup",
|
||||
"step3": "Copy the connection URL from the connection info section",
|
||||
"step4": "Test the connection from your charging station",
|
||||
"rememberLabel": "Remember:",
|
||||
"rememberText": "You can always access this help content by clicking the Help button in the sidebar.",
|
||||
"copied": "Copied!",
|
||||
"noWebsocketServers": "No websocket servers available.",
|
||||
"securityProfiles": {
|
||||
"0": {
|
||||
"label": "No Authentication",
|
||||
"description": "The charging station connects without credentials. Set up the username/password using this connection"
|
||||
},
|
||||
"1": {
|
||||
"label": "Security Profile 1: Unsecured Transport with Basic Authentication",
|
||||
"description": "Charging Station authentication is done through a username and password. Install CA certificate in the charging station using this connection"
|
||||
},
|
||||
"2": {
|
||||
"label": "Security Profile 2: TLS with Basic Authentication",
|
||||
"description": "The CSMS authenticates itself using a TLS server certificate. The Charging Stations authenticate themselves using HTTP Basic Authentication. Sign charging station certificate using this connection"
|
||||
},
|
||||
"3": {
|
||||
"label": "Security Profile 3: TLS with Client Side Certificates",
|
||||
"description": "Both the Charging Station and CSMS authenticate themselves using certificates."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"pages": {
|
||||
"login": {
|
||||
"title": "Sign in to your account",
|
||||
"signin": "Sign in",
|
||||
"signup": "Sign up",
|
||||
"divider": "or",
|
||||
"fields": {
|
||||
"email": "Email",
|
||||
"password": "Password"
|
||||
},
|
||||
"errors": {
|
||||
"validEmail": "Invalid email address",
|
||||
"requiredEmail": "Email is required",
|
||||
"requiredPassword": "Password is required"
|
||||
},
|
||||
"buttons": {
|
||||
"submit": "Login",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"noAccount": "Don’t have an account?",
|
||||
"rememberMe": "Remember me"
|
||||
}
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Forgot your password?",
|
||||
"fields": {
|
||||
"email": "Email"
|
||||
},
|
||||
"errors": {
|
||||
"validEmail": "Invalid email address",
|
||||
"requiredEmail": "Email is required"
|
||||
},
|
||||
"buttons": {
|
||||
"submit": "Send reset instructions"
|
||||
}
|
||||
},
|
||||
"register": {
|
||||
"title": "Sign up for your account",
|
||||
"fields": {
|
||||
"email": "Email",
|
||||
"password": "Password"
|
||||
},
|
||||
"errors": {
|
||||
"validEmail": "Invalid email address",
|
||||
"requiredEmail": "Email is required",
|
||||
"requiredPassword": "Password is required"
|
||||
},
|
||||
"buttons": {
|
||||
"submit": "Register",
|
||||
"haveAccount": "Have an account?"
|
||||
}
|
||||
},
|
||||
"updatePassword": {
|
||||
"title": "Update password",
|
||||
"fields": {
|
||||
"password": "New Password",
|
||||
"confirmPassword": "Confirm new password"
|
||||
},
|
||||
"errors": {
|
||||
"confirmPasswordNotMatch": "Passwords do not match",
|
||||
"requiredPassword": "Password required",
|
||||
"requiredConfirmPassword": "Confirm password is required"
|
||||
},
|
||||
"buttons": {
|
||||
"submit": "Update"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"info": "You may have forgotten to add the {action} component to {resource} resource.",
|
||||
"404": "Sorry, the page you visited does not exist.",
|
||||
"resource404": "Are you sure you have created the {resource} resource.",
|
||||
"backHome": "Back Home"
|
||||
},
|
||||
"redirectingToLogin": "Redirecting to login...",
|
||||
"checkingAuth": "Checking authentication..."
|
||||
},
|
||||
"actions": {
|
||||
"create": "Create",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"list": "List",
|
||||
"show": "Show"
|
||||
},
|
||||
"buttons": {
|
||||
"add": "Add",
|
||||
"cancel": "Cancel",
|
||||
"clear": "Clear",
|
||||
"clone": "Clone",
|
||||
"columns": "Columns",
|
||||
"confirm": "Are you sure?",
|
||||
"confirmText": "Confirm",
|
||||
"create": "Create",
|
||||
"delete": "Delete",
|
||||
"download": "Download",
|
||||
"edit": "Edit",
|
||||
"exportToCsv": "Export to CSV",
|
||||
"filter": "Filter",
|
||||
"import": "Import",
|
||||
"logout": "Logout",
|
||||
"notAccessTitle": "You don't have permission to access this resource.",
|
||||
"ok": "OK",
|
||||
"refresh": "Refresh",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"show": "Show",
|
||||
"submit": "Submit",
|
||||
"undo": "Undo",
|
||||
"upload": "Upload"
|
||||
},
|
||||
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
|
||||
"notifications": {
|
||||
"success": "Successful",
|
||||
"error": "Error (status code: {statusCode})",
|
||||
"undoable": "You have {seconds} seconds to undo",
|
||||
"createSuccess": "Successfully created {resource}",
|
||||
"createError": "There was an error creating {resource} (status code: {statusCode})",
|
||||
"deleteSuccess": "Successfully deleted {resource}",
|
||||
"deleteError": "Error when deleting {resource} (status code: {statusCode})",
|
||||
"editSuccess": "Successfully edited {resource}",
|
||||
"editError": "Error when editing {resource} (status code: {statusCode})",
|
||||
"importProgress": "Importing: {processed}/{total}"
|
||||
},
|
||||
"accessDenied": "Access Denied",
|
||||
"imageUploadFailed": "Image upload failed.",
|
||||
"loading": "Loading",
|
||||
"noDataFound": "No Data Found",
|
||||
"somethingWentWrong": "Something went wrong.",
|
||||
"loggedOut": "Logged out! Redirecting to login...",
|
||||
"tags": {
|
||||
"clone": "Clone"
|
||||
},
|
||||
"table": {
|
||||
"actions": "Actions",
|
||||
"bulkActions": "Bulk Actions",
|
||||
"clearFilters": "Clear filters",
|
||||
"noResultsFound": "No results found",
|
||||
"selectAll": "Select all",
|
||||
"selected": "selected",
|
||||
"toggleColumns": "Toggle columns",
|
||||
"resetColumns": "Reset Columns"
|
||||
},
|
||||
"dialogs": {
|
||||
"areYouSure": "Are you sure?",
|
||||
"thisActionCannotBeUndone": "This action cannot be undone."
|
||||
},
|
||||
"placeholders": {
|
||||
"search": "Search",
|
||||
"select": "Select"
|
||||
},
|
||||
"pagination": {
|
||||
"buttons": {
|
||||
"goToFirstPage": "Go to first page",
|
||||
"goToNextPage": "Go to next page",
|
||||
"goToPreviousPage": "Go to previous page",
|
||||
"goToLastPage": "Go to last page"
|
||||
},
|
||||
"rowsPerPage": "Rows per page"
|
||||
},
|
||||
"menu": {
|
||||
"overview": "Overview",
|
||||
"help": "Help",
|
||||
"themes": {
|
||||
"changeTo": "Change to",
|
||||
"dark": "Dark",
|
||||
"light": "Light",
|
||||
"mode": "Mode"
|
||||
}
|
||||
},
|
||||
"telemetryConsentModal": {
|
||||
"title": "Anonymous Metrics Consent",
|
||||
"description": "CitrineOS collects anonymous usage metrics to help us improve the product. Would you like to send these metrics?",
|
||||
"reject": "Reject",
|
||||
"accept": "Accept"
|
||||
},
|
||||
"overview": {
|
||||
"activeTransactions": "Active Transactions",
|
||||
"chargerActivity": "Charger Activity",
|
||||
"chargerOnlineStatus": "Charger Online Status",
|
||||
"chargers": "Chargers",
|
||||
"errorLoadingData": "Error loading data",
|
||||
"plugInSuccessRate": "Plug-In Success Rate",
|
||||
"noActiveTransactions": "No active transactions.",
|
||||
"noChargersStatus": "No chargers currently have {status} status.",
|
||||
"viewAllChargers": "View all chargers",
|
||||
"viewAllLocations": "View all locations",
|
||||
"viewAllTransactions": "View all transactions"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Locations": {
|
||||
"Locations": "Locations",
|
||||
"location": "Location",
|
||||
"noDataFound": "No location found with id {id}."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Tariffs": {
|
||||
"Tariffs": "Tariffs",
|
||||
"tariff": "Tariff",
|
||||
"noDataFound": "No tariff found with id {id}."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"TenantPartners": {
|
||||
"TenantPartners": "Partners",
|
||||
"tenantPartner": "Partner",
|
||||
"noDataFound": "No partner found with id {id}."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"Transactions": {
|
||||
"Transactions": "Transactions",
|
||||
"transaction": "Transaction",
|
||||
"toggleActiveStatus": "Toggle Active Status",
|
||||
"toggleActiveWarning": "You are about to mark this transaction as ",
|
||||
"toggleActiveCaution": "Warning: This action may cause the UI to be out of sync with the actual transaction status. Only proceed if you are certain.",
|
||||
"noDataFound": "No transaction found with id {id}."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<svg viewBox="0 0 433 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M58.9859 66.4936L23.3987 73.5563L0.174332 78.2353V19.4387L58.9859 66.4936Z" fill="url(#paint0_linear_173_959)"/>
|
||||
<path d="M58.9859 66.4936L35.6732 0.722595L0.174332 19.4386L58.9859 66.4936Z" fill="#F9B41E"/>
|
||||
<path d="M58.9859 66.4935L35.4966 99.1583L24.0169 73.4679L58.9859 66.4935Z" fill="#E18A26"/>
|
||||
<path opacity="0.4" d="M35.6732 0.722595L0.174332 19.4386L35.4966 99.1584L35.6732 0.722595Z" fill="url(#paint1_linear_173_959)"/>
|
||||
<path d="M35.4966 99.1584L0.174332 78.2353L24.1052 73.3797L35.4966 99.1584Z" fill="#D57428"/>
|
||||
<path d="M123.165 69.7672C121.433 80.5407 114.121 90.7371 97.8602 90.7371C77.751 90.7371 70.5347 76.1159 70.5347 57.9356C70.5347 40.0438 79.7715 25.3264 98.3413 25.3264C115.564 25.3264 122.107 36.2923 122.78 46.1039H110.08C108.733 40.9095 106.712 35.7152 97.9565 35.7152C87.3726 35.7152 83.8126 46.3925 83.8126 57.9356C83.8126 70.0558 87.1802 80.4445 98.0527 80.4445C106.616 80.4445 109.118 74.4806 110.272 69.7672H123.165Z" fill="black"/>
|
||||
<path d="M130.093 33.8875V22.2482H142.697V33.8875H130.093ZM130.093 41.679H142.697V89.7751H130.093V41.679Z" fill="black"/>
|
||||
<path d="M148.47 41.6791H155.301V28.6931H167.81V41.6791H176.373V51.4907H167.81V76.212C167.81 79.4826 168.676 80.8293 172.524 80.8293C173.679 80.8293 174.064 80.8293 175.411 80.6369V89.2942C172.909 90.1599 169.349 90.2561 167.713 90.2561C158.38 90.2561 155.301 86.5046 155.301 77.6549V51.5869H148.47V41.6791Z" fill="black"/>
|
||||
<path d="M182.338 58.0317C182.338 50.1439 182.338 45.0457 182.146 41.679H194.462C194.75 43.7952 194.846 46.8734 194.846 50.1439C196.386 46.1039 200.716 40.9095 209.471 40.9095V53.703C199.561 53.5107 195.039 56.8774 195.039 67.9395V89.7751H182.338V58.0317Z" fill="black"/>
|
||||
<path d="M215.822 33.8875V22.2482H228.426V33.8875H215.822ZM215.822 41.679H228.426V89.7751H215.822V41.679Z" fill="black"/>
|
||||
<path d="M237.567 56.7812C237.567 46.8734 237.47 43.8914 237.374 41.7752H249.69C249.882 42.7371 250.171 45.8153 250.171 48.0277C252.095 44.2762 256.714 40.9095 263.545 40.9095C273.648 40.9095 278.747 47.4506 278.747 58.5126V89.9675H266.047V60.7251C266.047 55.2421 264.7 51.202 258.734 51.202C252.961 51.202 250.171 54.5688 250.171 63.0337V89.8713H237.567V56.7812Z" fill="black"/>
|
||||
<path d="M297.028 68.6129C297.028 74.4806 299.241 80.8293 306.458 80.8293C312.231 80.8293 313.963 77.174 314.732 75.0578H327.048C324.739 83.0417 319.062 90.6409 306.073 90.6409C290.774 90.6409 284.328 80.1559 284.328 65.8233C284.328 53.8955 290.293 40.7172 306.65 40.7172C322.814 40.7172 327.625 52.2602 327.625 64.669C327.625 65.3424 327.529 67.8433 327.433 68.6129H297.028ZM315.31 60.3404C315.117 54.9536 312.904 50.2402 306.361 50.2402C299.241 50.2402 297.413 56.1079 297.125 60.3404H315.31Z" fill="black"/>
|
||||
<path d="M386.702 57.2621C386.702 75.731 378.235 90.8332 359.954 90.8332C342.924 90.8332 333.879 77.0777 333.879 57.3583C333.879 40.1399 342.635 24.653 360.724 24.653C377.754 24.5568 386.702 38.2161 386.702 57.2621ZM340.133 57.1659C340.133 73.0376 346.676 85.4464 360.339 85.4464C374.29 85.4464 380.544 73.1338 380.544 57.3583C380.544 41.5828 374.098 30.0397 360.531 30.0397C346.388 30.0397 340.133 42.2561 340.133 57.1659Z" fill="black"/>
|
||||
<path d="M396.901 71.9796C398.248 81.214 404.598 85.3503 412.488 85.3503C420.378 85.3503 426.343 81.1178 426.343 73.0377C426.343 65.4385 423.072 62.168 411.045 58.3203C398.152 54.2802 392.956 50.0477 392.956 40.8133C392.956 31.2903 399.98 24.653 411.911 24.653C425.189 24.653 430.866 32.8293 431.539 41.4866H425.381C424.323 34.2722 419.993 29.8474 411.622 29.8474C403.444 29.8474 399.307 33.8875 399.307 40.3323C399.307 46.7772 402.77 49.5668 413.45 52.8373C429.134 57.5507 432.79 63.5147 432.79 72.2681C432.79 83.0417 425.285 90.3523 412.296 90.3523C399.21 90.3523 391.802 83.234 390.84 71.8834H396.901V71.9796Z" fill="black"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_173_959" x1="42.6364" y1="27.5645" x2="4.61193" y2="72.3761" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.1723" stop-color="#F7EF79"/>
|
||||
<stop offset="0.5313" stop-color="#F9B41E"/>
|
||||
<stop offset="0.6042" stop-color="#F8AC24"/>
|
||||
<stop offset="0.7357" stop-color="#F6962E"/>
|
||||
<stop offset="0.8689" stop-color="#F47C36"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_173_959" x1="0.174332" y1="49.953" x2="35.6953" y2="49.953" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.1723" stop-color="#FBF8CD"/>
|
||||
<stop offset="0.3223" stop-color="#FAF6BA"/>
|
||||
<stop offset="0.709" stop-color="#F8F18C"/>
|
||||
<stop offset="0.8942" stop-color="#F7EF79"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,56 @@
|
||||
<svg
|
||||
viewBox="0 0 64 107"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M63.7899 71.4933L25.6683 79.1704L0.789948 84.2565V20.3444L63.7899 71.4933Z"
|
||||
fill="url(#paint0_linear_4_16)"
|
||||
/>
|
||||
<path
|
||||
d="M63.7899 71.4933L38.817 0L0.789948 20.3444L63.7899 71.4933Z"
|
||||
fill="#F9B41E"
|
||||
/>
|
||||
<path
|
||||
d="M63.7899 71.4933L38.6278 107L26.3305 79.0745L63.7899 71.4933Z"
|
||||
fill="#E18A26"
|
||||
/>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M38.817 0L0.789948 20.3444L38.6278 107L38.817 0Z"
|
||||
fill="url(#paint1_linear_4_16)"
|
||||
/>
|
||||
<path
|
||||
d="M38.6278 107L0.789948 84.2565L26.4251 78.9785L38.6278 107Z"
|
||||
fill="#D57428"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_4_16"
|
||||
x1="46.2761"
|
||||
y1="29.1772"
|
||||
x2="4.84919"
|
||||
y2="77.2896"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.1723" stop-color="#F7EF79"/>
|
||||
<stop offset="0.5313" stop-color="#F9B41E"/>
|
||||
<stop offset="0.6042" stop-color="#F8AC24"/>
|
||||
<stop offset="0.7357" stop-color="#F6962E"/>
|
||||
<stop offset="0.8689" stop-color="#F47C36"/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_4_16"
|
||||
x1="0.789948"
|
||||
y1="53.5136"
|
||||
x2="38.8406"
|
||||
y2="53.5136"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.1723" stop-color="#FBF8CD"/>
|
||||
<stop offset="0.3223" stop-color="#FAF6BA"/>
|
||||
<stop offset="0.709" stop-color="#F8F18C"/>
|
||||
<stop offset="0.8942" stop-color="#F7EF79"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,94 @@
|
||||
<svg
|
||||
width="433"
|
||||
height="100"
|
||||
viewBox="0 0 433 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M58.9859 66.3614L23.3987 73.424L0.174332 78.1031V19.3064L58.9859 66.3614Z"
|
||||
fill="url(#paint0_linear_173_991)"
|
||||
/>
|
||||
<path
|
||||
d="M58.9859 66.3614L35.6732 0.590332L0.174332 19.3064L58.9859 66.3614Z"
|
||||
fill="#F9B41E"
|
||||
/>
|
||||
<path
|
||||
d="M58.9859 66.3613L35.4966 99.0261L24.0169 73.3356L58.9859 66.3613Z"
|
||||
fill="#E18A26"
|
||||
/>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M35.6732 0.590332L0.174332 19.3064L35.4966 99.0262L35.6732 0.590332Z"
|
||||
fill="url(#paint1_linear_173_991)"
|
||||
/>
|
||||
<path
|
||||
d="M35.4966 99.0261L0.174332 78.103L24.1052 73.2474L35.4966 99.0261Z"
|
||||
fill="#D57428"
|
||||
/>
|
||||
<path
|
||||
d="M123.165 69.6349C121.433 80.4084 114.121 90.6048 97.8602 90.6048C77.751 90.6048 70.5347 75.9836 70.5347 57.8033C70.5347 39.9116 79.7715 25.1942 98.3413 25.1942C115.564 25.1942 122.107 36.1601 122.78 45.9717H110.08C108.733 40.7773 106.712 35.5829 97.9565 35.5829C87.3726 35.5829 83.8126 46.2602 83.8126 57.8033C83.8126 69.9235 87.1802 80.3123 98.0527 80.3123C106.616 80.3123 109.118 74.3483 110.272 69.6349H123.165Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M130.093 33.7552V22.116H142.697V33.7552H130.093ZM130.093 41.5468H142.697V89.6429H130.093V41.5468Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M148.47 41.5468H155.301V28.5609H167.81V41.5468H176.373V51.3584H167.81V76.0798C167.81 79.3503 168.676 80.697 172.524 80.697C173.679 80.697 174.064 80.697 175.411 80.5046V89.1619C172.909 90.0276 169.349 90.1238 167.713 90.1238C158.38 90.1238 155.301 86.3723 155.301 77.5227V51.4546H148.47V41.5468Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M182.338 57.8994C182.338 50.0117 182.338 44.9135 182.146 41.5468H194.462C194.75 43.663 194.846 46.7411 194.846 50.0117C196.386 45.9716 200.716 40.7772 209.471 40.7772V53.5708C199.561 53.3784 195.039 56.7451 195.039 67.8072V89.6428H182.338V57.8994Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M215.822 33.7552V22.116H228.426V33.7552H215.822ZM215.822 41.5468H228.426V89.6429H215.822V41.5468Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M237.567 56.6489C237.567 46.7411 237.47 43.7592 237.374 41.6429H249.69C249.882 42.6049 250.171 45.683 250.171 47.8954C252.095 44.1439 256.714 40.7772 263.545 40.7772C273.648 40.7772 278.747 47.3183 278.747 58.3804V89.8352H266.047V60.5928C266.047 55.1099 264.7 51.0698 258.734 51.0698C252.961 51.0698 250.171 54.4365 250.171 62.9014V89.739H237.567V56.6489Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M297.028 68.4806C297.028 74.3483 299.241 80.697 306.458 80.697C312.231 80.697 313.963 77.0417 314.732 74.9255H327.048C324.739 82.9094 319.062 90.5086 306.073 90.5086C290.774 90.5086 284.328 80.0237 284.328 65.691C284.328 53.7632 290.293 40.5849 306.65 40.5849C322.814 40.5849 327.625 52.128 327.625 64.5367C327.625 65.2101 327.529 67.7111 327.433 68.4806H297.028ZM315.31 60.2081C315.117 54.8213 312.904 50.1079 306.361 50.1079C299.241 50.1079 297.413 55.9756 297.125 60.2081H315.31Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M386.702 57.1298C386.702 75.5987 378.235 90.7009 359.954 90.7009C342.924 90.7009 333.879 76.9454 333.879 57.226C333.879 40.0077 342.635 24.5207 360.724 24.5207C377.754 24.4245 386.702 38.0838 386.702 57.1298ZM340.133 57.0337C340.133 72.9054 346.676 85.3142 360.339 85.3142C374.29 85.3142 380.544 73.0016 380.544 57.226C380.544 41.4505 374.098 29.9075 360.531 29.9075C346.388 29.9075 340.133 42.1239 340.133 57.0337Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M396.901 71.8473C398.248 81.0817 404.598 85.218 412.488 85.218C420.378 85.218 426.343 80.9855 426.343 72.9054C426.343 65.3062 423.072 62.0357 411.045 58.188C398.152 54.1479 392.956 49.9155 392.956 40.681C392.956 31.158 399.98 24.5208 411.911 24.5208C425.189 24.5208 430.866 32.6971 431.539 41.3544H425.381C424.323 34.14 419.993 29.7151 411.622 29.7151C403.444 29.7151 399.307 33.7552 399.307 40.2001C399.307 46.6449 402.77 49.4345 413.45 52.7051C429.134 57.4185 432.79 63.3824 432.79 72.1359C432.79 82.9094 425.285 90.22 412.296 90.22C399.21 90.22 391.802 83.1018 390.84 71.7511H396.901V71.8473Z"
|
||||
fill="white"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_173_991"
|
||||
x1="42.6364"
|
||||
y1="27.4323"
|
||||
x2="4.61193"
|
||||
y2="72.2438"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.1723" stop-color="#F7EF79" />
|
||||
<stop offset="0.5313" stop-color="#F9B41E" />
|
||||
<stop offset="0.6042" stop-color="#F8AC24" />
|
||||
<stop offset="0.7357" stop-color="#F6962E" />
|
||||
<stop offset="0.8689" stop-color="#F47C36" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_173_991"
|
||||
x1="0.174332"
|
||||
y1="49.8208"
|
||||
x2="35.6953"
|
||||
y2="49.8208"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.1723" stop-color="#FBF8CD" />
|
||||
<stop offset="0.3223" stop-color="#FAF6BA" />
|
||||
<stop offset="0.709" stop-color="#F8F18C" />
|
||||
<stop offset="0.8942" stop-color="#F7EF79" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
BIN
tools/citrineos-core-main/apps/operator-ui/public/logo_black.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
tools/citrineos-core-main/apps/operator-ui/public/logo_white.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
tools/citrineos-core-main/apps/operator-ui/public/online.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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 />;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||