Compare commits

..

11 commits

55 changed files with 2151 additions and 499 deletions

View file

@ -1,5 +1,6 @@
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import APIRouter, FastAPI from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.authentication import AuthenticationMiddleware
load_dotenv() load_dotenv()
@ -47,6 +48,19 @@ def create_app():
app.include_router(api_v1_router) app.include_router(api_v1_router)
app.mount(path="/v1/ocpp", app=create_ocpp_app()) app.mount(path="/v1/ocpp", app=create_ocpp_app())
origins = [
"http://localhost",
"http://localhost:5173",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
return app return app
app = create_app() app = create_app()

View file

@ -25,3 +25,4 @@ class ChargePoint(Base):
connectors = relationship("Connector", cascade="delete, delete-orphan") connectors = relationship("Connector", cascade="delete, delete-orphan")
transactions = relationship("Transaction", cascade="delete, delete-orphan") transactions = relationship("Transaction", cascade="delete, delete-orphan")
variables = relationship("ChargepointVariable", cascade="delete, delete-orphan") variables = relationship("ChargepointVariable", cascade="delete, delete-orphan")
firmware_updates = relationship("FirmwareUpdate", cascade="delete, delete-orphan")

View file

@ -1,5 +1,6 @@
import uuid import uuid
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, Uuid from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, Uuid
from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
from app.schemas.firmware_update import FirmwareUpdateStatus from app.schemas.firmware_update import FirmwareUpdateStatus
@ -20,3 +21,4 @@ class FirmwareUpdate(Base):
signature = Column(String, nullable=True) signature = Column(String, nullable=True)
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True) chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True)
chargepoint = relationship("ChargePoint", back_populates="firmware_updates")

View file

@ -1,5 +1,6 @@
import uuid import uuid
from sqlalchemy import Uuid, Column, DateTime, Enum, Float, ForeignKey, String from sqlalchemy import Uuid, Column, DateTime, Enum, Float, ForeignKey, String
from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
from app.schemas.meter_value import Measurand, PhaseType from app.schemas.meter_value import Measurand, PhaseType
@ -15,3 +16,4 @@ class MeterValue(Base):
value = Column(Float) value = Column(Float)
transaction_id = Column(String, ForeignKey("transactions.id"), index=True) transaction_id = Column(String, ForeignKey("transactions.id"), index=True)
transaction = relationship("Transaction", back_populates="meter_values")

View file

@ -1,5 +1,6 @@
import uuid import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, Uuid from sqlalchemy import Column, DateTime, ForeignKey, String, Uuid
from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
@ -12,3 +13,4 @@ class Session(Base):
last_used = Column(DateTime(timezone=True)) last_used = Column(DateTime(timezone=True))
user_id = Column(Uuid, ForeignKey("users.id"), nullable=False, index=True) user_id = Column(Uuid, ForeignKey("users.id"), nullable=False, index=True)
user = relationship("User", back_populates="sessions")

View file

@ -1,4 +1,5 @@
from sqlalchemy import String, Uuid, Column, DateTime, Enum, Numeric, ForeignKey from sqlalchemy import String, Uuid, Column, DateTime, Enum, Numeric, ForeignKey
from sqlalchemy.orm import relationship
from app.schemas.transaction import TransactionEventTriggerReason, TransactionStatus from app.schemas.transaction import TransactionEventTriggerReason, TransactionStatus
from app.database import Base from app.database import Base
@ -11,9 +12,14 @@ class Transaction(Base):
started_at = Column(DateTime, index=True) started_at = Column(DateTime, index=True)
ended_at = Column(DateTime, nullable=True, index=True) ended_at = Column(DateTime, nullable=True, index=True)
meter_start = Column(Numeric(10,2)) meter_start = Column(Numeric(10,2))
meter_end = Column(Numeric(10,2), nullable=True) meter_end = Column(Numeric(10,2))
end_reason = Column(Enum(TransactionEventTriggerReason), nullable=True) end_reason = Column(Enum(TransactionEventTriggerReason), nullable=True)
price = Column(Numeric(10,2)) price = Column(Numeric(10,2))
user_id = Column(Uuid, ForeignKey("users.id"), nullable=True, index=True) user_id = Column(Uuid, ForeignKey("users.id"), nullable=True, index=True)
user = relationship("User", back_populates="transactions")
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True) chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True)
chargepoint = relationship("ChargePoint", back_populates="transactions")
meter_values = relationship("MeterValue", cascade="delete, delete-orphan")

View file

@ -17,3 +17,4 @@ class User(Base):
id_tokens = relationship("IdToken", back_populates="owner", cascade="delete, delete-orphan") id_tokens = relationship("IdToken", back_populates="owner", cascade="delete, delete-orphan")
transactions = relationship("Transaction", cascade="delete, delete-orphan") transactions = relationship("Transaction", cascade="delete, delete-orphan")
sessions = relationship("Session", cascade="delete, delete-orphan")

View file

@ -1,6 +1,8 @@
from datetime import UTC, datetime, timedelta
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.params import Depends from fastapi.params import Depends
from sqlalchemy import select
from sqlalchemy.orm import Session as DbSession from sqlalchemy.orm import Session as DbSession
from app.database import get_db from app.database import get_db
@ -10,6 +12,9 @@ from app.schemas.user import PasswordUpdate, UserUpdate, User
from app.security.jwt_bearer import JWTBearer from app.security.jwt_bearer import JWTBearer
from app.services import session_service, user_service from app.services import session_service, user_service
from app.util.errors import InvalidStateError, NotFoundError from app.util.errors import InvalidStateError, NotFoundError
from app.schemas.dashboard import DashboardResponse, DashboardStats
from app.models.transaction import Transaction as DbTransaction
from app.schemas.transaction import TransactionStatus
router = APIRouter(prefix="/me", tags=["Me (v1)"]) router = APIRouter(prefix="/me", tags=["Me (v1)"])
@ -109,3 +114,74 @@ async def delete_user_session(
except NotFoundError: except NotFoundError:
raise HTTPException(status_code=404, detail="session_not_found") raise HTTPException(status_code=404, detail="session_not_found")
return list() return list()
@router.get(path="/dashboard", response_model=DashboardResponse)
async def get_dashboard(
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
):
"""
Get dashboard information for the currently authenticated user.
"""
# Currently ongoing transaction
stmt_current_transaction = select(DbTransaction).where(DbTransaction.user_id == token.subject).where(DbTransaction.status == TransactionStatus.ONGOING)
current_transaction = db.execute(stmt_current_transaction).scalars().first()
# Common base query
stmt_base = select(DbTransaction).where(DbTransaction.user_id == token.subject).where( DbTransaction.status == TransactionStatus.ENDED).order_by(DbTransaction.ended_at.desc())
# 5 most recent transactions
stmt_transactions_recent = stmt_base.limit(5)
recent_transactions = db.execute(stmt_transactions_recent).scalars().all()
# Calculate beginning of the current and previous month
current_date = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
beginning_current_month = current_date.replace(day=1)
beginning_previous_month = beginning_current_month.replace(
year=(beginning_current_month.year - 1 if beginning_current_month.month == 1 else beginning_current_month.year),
month=(12 if beginning_current_month.month == 1 else beginning_current_month.month - 1)
)
# Transactions for total calculations
stmt_transactions = stmt_base.where(DbTransaction.ended_at >= beginning_previous_month)
transactions = db.execute(stmt_transactions).scalars().all()
# Current month totals
count = 0
energy_total = 0
cost_total = 0
# Previous month totals
count_previous = 0
energy_total_previous = 0
cost_total_previous = 0
# Calculate totals
for trans in transactions:
trans_energy = trans.meter_end - trans.meter_start
trans_cost = trans_energy * trans.price
if trans.ended_at >= beginning_current_month:
# Current month
energy_total += trans_energy
cost_total += trans_cost
count += 1
else:
# Previous month
energy_total_previous += trans_energy
cost_total_previous += trans_cost
count_previous += 1
return DashboardResponse(
stats=DashboardStats(
transaction_count=count,
transaction_count_previous=count_previous,
transaction_energy_total=energy_total,
transaction_energy_total_previous=energy_total_previous,
transaction_cost_total=cost_total,
transaction_cost_total_previous=cost_total_previous
),
current_transaction=current_transaction,
recent_transactions=recent_transactions
)

View file

@ -3,6 +3,7 @@ from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
from app.schemas.user import Role from app.schemas.user import Role
from app.util.encoders import force_utc_datetime
@dataclass @dataclass
@ -19,3 +20,6 @@ class TokenResponse(BaseModel):
access_token: str access_token: str
refresh_token: str refresh_token: str
not_after: datetime not_after: datetime
class Config:
json_encoders = {datetime: force_utc_datetime}

View file

@ -8,6 +8,8 @@ from app.schemas.connector import Connector
from ocpp.v201.enums import ResetEnumType, ResetStatusEnumType from ocpp.v201.enums import ResetEnumType, ResetStatusEnumType
from app.util.encoders import decimal_encoder, force_utc_datetime
class ChargePointBase(BaseModel): class ChargePointBase(BaseModel):
identity: str identity: str
is_active: bool is_active: bool
@ -32,6 +34,16 @@ class ChargePoint(ChargePointBase):
class Config: class Config:
from_attributes = True from_attributes = True
json_encoders = {Decimal: decimal_encoder, datetime: force_utc_datetime}
class ChargePointThumb(BaseModel):
id: UUID
identity: str
price: Decimal
class Config:
from_attributes = True
json_encoders = {Decimal: decimal_encoder}
class ChargePointPassword(BaseModel): class ChargePointPassword(BaseModel):
password: str password: str

View file

@ -4,6 +4,8 @@ from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
import enum import enum
from app.util.encoders import decimal_encoder
class AttributeType(enum.Enum): class AttributeType(enum.Enum):
ACTUAL = "Actual" ACTUAL = "Actual"
TARGET = "Target" TARGET = "Target"
@ -52,6 +54,7 @@ class ChargepointVariable(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
json_encoders = {Decimal: decimal_encoder}
class ChargepointVariableUpdate(BaseModel): class ChargepointVariableUpdate(BaseModel):
value: str value: str

View file

@ -0,0 +1,25 @@
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel
from app.schemas.transaction import Transaction
from app.util.encoders import decimal_encoder
class DashboardStats(BaseModel):
transaction_count: int
transaction_count_previous: int
transaction_energy_total: Decimal
transaction_energy_total_previous: Decimal
transaction_cost_total: Decimal
transaction_cost_total_previous: Decimal
class Config:
json_encoders = {Decimal: decimal_encoder}
class DashboardResponse(BaseModel):
stats: DashboardStats
current_transaction: Optional[Transaction] = None
recent_transactions: list[Transaction]
class Config:
json_encoders = {Decimal: decimal_encoder}

View file

@ -5,6 +5,8 @@ from typing import Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
from app.util.encoders import decimal_encoder, force_utc_datetime
class PhaseType(enum.Enum): class PhaseType(enum.Enum):
L1 = "L1" L1 = "L1"
L2 = "L2" L2 = "L2"
@ -55,3 +57,4 @@ class MeterValue(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
json_encoders = {Decimal: decimal_encoder, datetime: force_utc_datetime}

View file

@ -2,10 +2,14 @@ from datetime import datetime
from uuid import UUID from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
from app.util.encoders import force_utc_datetime
class Session(BaseModel): class Session(BaseModel):
id: UUID id: UUID
name: str name: str
last_used: datetime last_used: datetime
model_config = {"from_attributes": True} class Config:
from_attributes = True
json_encoders = {datetime: force_utc_datetime}

View file

@ -1,10 +1,13 @@
import enum
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
import enum
from typing import Optional from typing import Optional
from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
from app.schemas.chargepoint import ChargePointThumb
from app.schemas.user import UserThumb
from app.util.encoders import decimal_encoder, force_utc_datetime
class TransactionStatus(enum.Enum): class TransactionStatus(enum.Enum):
ONGOING = "ongoing" ONGOING = "ongoing"
ENDED = "ended" ENDED = "ended"
@ -42,14 +45,15 @@ class Transaction(BaseModel):
started_at: datetime started_at: datetime
ended_at: Optional[datetime] = None ended_at: Optional[datetime] = None
meter_start: Decimal meter_start: Decimal
meter_end: Optional[Decimal] = None meter_end: Decimal
end_reason: Optional[TransactionEventTriggerReason] = None end_reason: Optional[TransactionEventTriggerReason] = None
price: Decimal price: Decimal
user_id: Optional[UUID] = None user: UserThumb
chargepoint_id: UUID chargepoint: ChargePointThumb
class Config: class Config:
from_attributes = True from_attributes = True
json_encoders = {Decimal: decimal_encoder, datetime: force_utc_datetime}
class RemoteTransactionStartStopResponse(BaseModel): class RemoteTransactionStartStopResponse(BaseModel):
status: RemoteTransactionStartStopStatus status: RemoteTransactionStartStopStatus

View file

@ -30,6 +30,13 @@ class User(UserBase):
class Config: class Config:
from_attributes = True from_attributes = True
class UserThumb(BaseModel):
id: UUID
friendly_name: str
class Config:
from_attributes = True
class PasswordUpdate(BaseModel): class PasswordUpdate(BaseModel):
old_password: str = Field(max_length=100) old_password: str = Field(max_length=100)
new_password: str = Field(max_length=100) new_password: str = Field(max_length=100)

View file

@ -28,7 +28,7 @@ class JWTBearer(HTTPBearer):
if credentials: if credentials:
if not credentials.scheme == "Bearer": if not credentials.scheme == "Bearer":
raise HTTPException( raise HTTPException(
status_code=403, detail="authentication_scheme_invalid" status_code=401, detail="authentication_scheme_invalid"
) )
try: try:
token = await token_service.verify_access_token( token = await token_service.verify_access_token(
@ -36,7 +36,7 @@ class JWTBearer(HTTPBearer):
) )
if not token: if not token:
raise HTTPException( raise HTTPException(
status_code=403, detail="token_invalid_or_expired" status_code=401, detail="token_invalid_or_expired"
) )
return token return token
except InsufficientPermissionsError: except InsufficientPermissionsError:
@ -44,4 +44,4 @@ class JWTBearer(HTTPBearer):
except InvalidTokenAudienceError: except InvalidTokenAudienceError:
raise HTTPException(status_code=403, detail="invalid_token_audience") raise HTTPException(status_code=403, detail="invalid_token_audience")
else: else:
raise HTTPException(status_code=403, detail="authorization_code_invalid") raise HTTPException(status_code=401, detail="authorization_code_invalid")

View file

@ -21,19 +21,23 @@ async def create_transaction(
with SessionLocal() as db: with SessionLocal() as db:
chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first() chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
meter_start=0 meter_start=0
meter_end=0
if "meter_value" in transaction_data.keys(): if "meter_value" in transaction_data.keys():
for meter_value_entry in transaction_data['meter_value']: for meter_value_entry in transaction_data['meter_value']:
for sampled_value in meter_value_entry['sampled_value']: for sampled_value in meter_value_entry['sampled_value']:
if "measurand" in sampled_value.keys(): if "measurand" in sampled_value.keys():
if sampled_value['measurand'] == str(Measurand.ENERGY_ACTIVE_IMPORT_REGISTER): if sampled_value['measurand'] == str(Measurand.ENERGY_ACTIVE_IMPORT_REGISTER):
meter_start = sampled_value['value'] meter_start = sampled_value['value']
meter_end = sampled_value['value']
else: else:
meter_start = sampled_value['value'] meter_start = sampled_value['value']
meter_end = sampled_value['value']
transaction = Transaction( transaction = Transaction(
id=transaction_info["transaction_id"], id=transaction_info["transaction_id"],
status=TransactionStatus.ONGOING, status=TransactionStatus.ONGOING,
started_at=timestamp, started_at=timestamp,
meter_start=meter_start, meter_start=meter_start,
meter_end=meter_end,
price=chargepoint.price, price=chargepoint.price,
chargepoint_id=chargepoint.id, chargepoint_id=chargepoint.id,
user_id=user_id user_id=user_id
@ -55,6 +59,12 @@ async def update_transaction(
transaction_id=transaction.id, transaction_id=transaction.id,
meter_value_data=meter_value_entry meter_value_data=meter_value_entry
) )
# Update current meter_end value
for sampled_value in meter_value_entry['sampled_value']:
if "measurand" in sampled_value.keys():
if sampled_value['measurand'] == str(Measurand.ENERGY_ACTIVE_IMPORT_REGISTER):
transaction.meter_end = sampled_value['value']
db.commit()
async def end_transaction( async def end_transaction(
transaction_id: str, transaction_id: str,

View file

@ -0,0 +1,27 @@
from datetime import datetime, timezone
from decimal import Decimal
from typing import Union
def decimal_encoder(dec_value: Decimal) -> Union[int, float]:
"""Encodes a Decimal as int of there's no exponent, otherwise float.
This is useful when we use ConstrainedDecimal to represent Numeric(x,0)
where a integer (but not int typed) is used. Encoding this as a float
results in failed round-tripping between encode and parse.
Our Id type is a prime example of this.
>>> decimal_encoder(Decimal("1.0"))
1.0
>>> decimal_encoder(Decimal("1"))
1
"""
exponent = dec_value.as_tuple().exponent
if isinstance(exponent, int) and exponent >= 0:
return int(dec_value)
else:
return float(dec_value)
def force_utc_datetime(datetime_value: datetime) -> datetime:
"""Force a datetime to be in the UTC timzone"""
return datetime_value.replace(tzinfo=timezone.utc)

File diff suppressed because it is too large Load diff

View file

@ -34,7 +34,14 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.0.14", "@tailwindcss/vite": "^4.0.14",
"axios": "^1.8.4",
"bootstrap-icons": "^1.11.3",
"daisyui": "^5.0.3", "daisyui": "^5.0.3",
"i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.0.4",
"i18next-chained-backend": "^4.6.2",
"i18next-fetch-backend": "^6.0.0",
"i18next-localstorage-backend": "^4.2.0",
"tailwindcss": "^4.0.14" "tailwindcss": "^4.0.14"
} }
} }

View file

@ -13,5 +13,17 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.tabSize": 2, "editor.tabSize": 2,
"editor.detectIndentation": false, "editor.detectIndentation": false,
"i18n-ally.enabledFrameworks": ["i18next", "svelte"],
"i18n-ally.localesPaths": ["static/locales"],
"i18n-ally.keystyle": "nested",
"i18n-ally.namespace": true,
"i18n-ally.editor.preferEditor": true,
"i18n-ally.refactor.templates": [
{
"templates": ["{{ t('{key}'{args}) }}"],
},
],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.displayLanguage": "en",
}, },
} }

42
frontend/src/app.css Normal file
View file

@ -0,0 +1,42 @@
@import 'tailwindcss';
@import 'bootstrap-icons';
@plugin "daisyui" {
themes:
emerald --default;
}
@plugin "daisyui/theme" {
name: "darkgray";
default: false;
prefersdark: true;
color-scheme: "light";
--color-base-100: oklch(37% 0.034 259.733);
--color-base-200: oklch(27% 0.033 256.848);
--color-base-300: oklch(21% 0.006 285.885);
--color-base-content: oklch(96% 0.001 286.375);
--color-primary: oklch(70% 0.14 182.503);
--color-primary-content: oklch(98% 0.014 180.72);
--color-secondary: oklch(65% 0.241 354.308);
--color-secondary-content: oklch(94% 0.028 342.258);
--color-accent: oklch(58% 0.233 277.117);
--color-accent-content: oklch(96% 0.018 272.314);
--color-neutral: oklch(20% 0 0);
--color-neutral-content: oklch(96% 0.001 286.375);
--color-info: oklch(74% 0.16 232.661);
--color-info-content: oklch(95% 0.026 236.824);
--color-success: oklch(76% 0.177 163.223);
--color-success-content: oklch(26% 0.051 172.552);
--color-warning: oklch(82% 0.189 84.429);
--color-warning-content: oklch(27% 0.077 45.635);
--color-error: oklch(64% 0.246 16.439);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.5rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}

View file

@ -0,0 +1,108 @@
import axios from 'axios';
import { dev } from '$app/environment';
import { goto } from '$app/navigation';
import { get } from 'svelte/store';
import { persistentSettings, clearLoginState } from '$lib/persistent_store';
if (dev) {
axios.defaults.baseURL = "http://localhost:8000/api/v1"
} else {
axios.defaults.baseURL = "/api/v1"
}
// Get access token from local storage
axios.defaults.headers.common['Authorization'] = "Bearer " + get(persistentSettings).accessToken;
function createTokenRefreshInterceptor() {
const interceptor = axios.interceptors.response.use(
(response) => response,
(error) => {
// Reject promise if usual error
if (error.response.status !== 401) {
return Promise.reject(error);
}
/*
* When response code is 401, try to refresh the token.
* Eject the interceptor so it doesn't loop in case
* token refresh causes the 401 response.
*
* Must be re-attached later on or the token refresh will only happen once
*/
axios.interceptors.response.eject(interceptor);
return axios
.post("/auth/refresh", {
refresh_token: get(persistentSettings).refreshToken,
})
.then((response) => {
// Save new tokens
persistentSettings.update(settings => {
settings.accessToken = response.data.access_token
settings.refreshToken = response.data.refresh_token;
return settings;
})
// Update access token
const authHeader = "Bearer " + response.data.access_token;
axios.defaults.headers.common['Authorization'] = authHeader;
error.response.config.headers["Authorization"] = authHeader;
// Retry initial request with new token
return axios(error.response.config);
})
.catch((retryError) => {
// Retry failed, clean up and reject the promise
clearLoginState();
axios.defaults.headers.common['Authorization'] = "";
goto('/login?reauth')
return Promise.reject(retryError);
})
.finally(createTokenRefreshInterceptor); // Re-attach interceptor for future requests
}
);
}
createTokenRefreshInterceptor();
export const login = async function(email: string, password: string) {
await axios
.post('/auth/login', {
email,
password,
})
.then((response) => {
persistentSettings.update(settings => {
settings.loggedIn = true
settings.accessToken = response.data.access_token
settings.refreshToken = response.data.refresh_token
return settings;
})
axios.defaults.headers.common['Authorization'] = 'Bearer ' + response.data.access_token
axios.get('/me').then((response) => {
persistentSettings.update(settings => {
settings.email = response.data.email
settings.friendlyName = response.data.friendly_name
settings.role = response.data.role
return settings;
})
})
goto('/')
return Promise.resolve();
})
.catch((error) => {
return Promise.reject(error);
})
}
export const logout = function() {
axios
.post('/auth/logout')
.then(() => {
clearLoginState();
axios.defaults.headers.common['Authorization'] = "";
goto('/login?logout')
});
}
export default axios;

View file

@ -0,0 +1,51 @@
<script lang="ts">
import i18n from '$lib/i18n'
let props: {
title: string
icon: string
value: number
previousValue: number
unit?: string
} = $props()
let diff = $derived(
Math.round(((props.previousValue - props.value) / props.previousValue) * 100 * 10 * -1) / 10
)
</script>
<div class="card bg-base-100 shadow rounded-md basis-0 grow">
<div class="card-body gap-2">
<div class="flex items-start justify-between gap-2 text-sm">
<div>
<p class="text-base-content/80 font-medium">
{props.title}
</p>
<div class="mt-3 flex items-center gap-2">
<p class="text-2xl font-semibold">
{props.value}{#if props.unit}{' ' + props.unit}{/if}
</p>
{#if !Number.isNaN(diff)}
{#if diff > 0}
<div class="badge badge-outline badge-success badge-sm gap-0.5 px-1 font-medium">
<i class="bi bi-arrow-up-right"></i>{diff}%
</div>
{:else if diff < 0}
<div class="badge badge-outline badge-error badge-sm gap-0.5 px-1 font-medium">
<i class="bi bi-arrow-down-right"></i>{diff}%
</div>
{:else}
<div class="badge badge-outline badge-warning badge-sm gap-0.5 px-1 font-medium">
<i class="bi bi-arrow-right"></i>{diff}%
</div>
{/if}
{/if}
</div>
</div>
<i class="bi {props.icon} text-primary text-4xl"></i>
</div>
<p class="text-base-content/60 text-sm">
{$i18n.t('dashboard:cards.lastMonth', { val: props.previousValue, unit: props.unit })}
</p>
</div>
</div>

View file

@ -0,0 +1,89 @@
<script lang="ts">
import i18n from '$lib/i18n'
import type { Transaction } from '$lib/types/transaction'
let props: {
transactions: Transaction[]
} = $props()
</script>
<div class="overflow-x-auto mt-4 bg-base-100 rounded-md">
<table class="table">
<thead>
<tr>
<th>{$i18n.t('common:transactionTable.headerDate')}</th>
<th>{$i18n.t('common:transactionTable.headerChargepoint')}</th>
<th>{$i18n.t('common:transactionTable.headerEnergyTotal')}</th>
<th>{$i18n.t('common:transactionTable.headerCost')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#if props.transactions.length > 0}
{#each props.transactions as transaction}
<tr>
<td>
<div class="flex items-center gap-3">
<div>
<p>
{$i18n.t('common:transactionTable.startTime', {
time: new Date(transaction.started_at),
formatParams: {
time: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
},
},
})}
</p>
<p>
{$i18n.t('common:transactionTable.endTime', {
time: new Date(transaction.ended_at!),
formatParams: {
time: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
},
},
})}
</p>
</div>
</div>
</td>
<td>
<a href="/chargepoint/{transaction.chargepoint.id}" class="btn btn-sm btn-primary">
<i class="bi bi-plug-fill text-lg"></i>
{transaction.chargepoint.identity}
</a>
</td>
<td class="font-bold"
>{(transaction.meter_end - transaction.meter_start).toFixed(2)} kWh</td
>
<td class="font-bold"
>{((transaction.meter_end - transaction.meter_start) * transaction.price).toFixed(2)}</td
>
<th>
<a href="/transaction/{transaction.id}" class="btn btn-sm btn-primary">
{$i18n.t('common:transactionTable.detailButton')}
<i class="bi bi-arrow-right"></i>
</a>
</th>
</tr>
{/each}
{/if}
</tbody>
</table>
{#if props.transactions.length === 0}
<p class="w-full mt-15 mb-15 text-center text-xl font-bold">
{$i18n.t('common:transactionTable.noPreviousTransactions')}
</p>
{/if}
</div>

32
frontend/src/lib/i18n.ts Normal file
View file

@ -0,0 +1,32 @@
import i18next from 'i18next'
import Backend from 'i18next-chained-backend'
import Fetch from 'i18next-fetch-backend'
import LocalStorageBackend from 'i18next-localstorage-backend'
import I18nextBrowserLanguageDetector from 'i18next-browser-languagedetector'
import { createI18nStore } from './i18n_store'
i18next
.use(Backend)
.use(I18nextBrowserLanguageDetector)
.init({
supportedLngs: ['en', 'de'],
ns: ['common'],
defaultNS: 'common',
backend: {
backends: [LocalStorageBackend, Fetch],
backendOptions: [
{
expirationTime: 24 * 60 * 60 * 1000,
},
{
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
],
},
interpolation: {
escapeValue: false,
},
})
const i18n = createI18nStore(i18next)
export default i18n

View file

@ -0,0 +1,55 @@
import type { i18n } from 'i18next'
import { writable, type Readable, type Writable } from 'svelte/store'
export interface TranslationService {
i18n: Readable<i18n>
}
export const isLoading = writable(true)
export class I18NextTranslationStore implements TranslationService {
public i18n: Writable<i18n>
public isLoading: Writable<boolean>
constructor(i18n: i18n) {
this.i18n = this.createInstance(i18n)
this.isLoading = this.createLoadingInstance(i18n)
}
private createInstance(i18n: i18n): Writable<i18n> {
const i18nWritable = writable(i18n)
i18n.on('initialized', () => {
i18nWritable.set(i18n)
})
i18n.on('loaded', () => {
i18nWritable.set(i18n)
})
i18n.on('added', () => i18nWritable.set(i18n))
i18n.on('languageChanged', () => {
i18nWritable.set(i18n)
})
return i18nWritable
}
private createLoadingInstance(i18n: i18n): Writable<boolean> {
// if loaded resources are empty || {}, set loading to true
i18n.on('loaded', (resources) => {
if (Object.keys(resources).length !== 0) {
isLoading.set(false)
}
})
// if resources failed loading, set loading to true
i18n.on('failedLoading', () => {
isLoading.set(true)
})
return isLoading
}
}
export const createI18nStore = (i18n: i18n) => {
const i18nStore = new I18NextTranslationStore(i18n)
return i18nStore.i18n
}

View file

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View file

@ -0,0 +1,37 @@
import { writable } from 'svelte/store'
interface PersistedSettings {
darkmode: boolean
loggedIn: boolean
friendlyName: string
email: string
role: string
accessToken: string,
refreshToken: string
}
const settingsDefault: PersistedSettings = {
darkmode: false,
loggedIn: false,
friendlyName: "",
email: "",
role: "member",
accessToken: "",
refreshToken: ""
}
export const persistentSettings = writable<PersistedSettings>(JSON.parse(localStorage.getItem('persistentSettings') || JSON.stringify(settingsDefault)))
persistentSettings.subscribe((value) => localStorage.persistentSettings = JSON.stringify(value))
export const clearLoginState = function() {
persistentSettings.update(settings => {
settings.accessToken = "";
settings.refreshToken = "";
settings.loggedIn = false;
settings.friendlyName = "";
settings.email = "";
settings.role = "member";
return settings;
})
}

View file

@ -0,0 +1,25 @@
export type ChargePoint = {
identity: string;
is_active: boolean;
price: number;
id: string;
last_seen: string;
vendor_name: string;
model: string;
serial_number: string,
firmware_version: string;
connectors: [
{
id: string;
evse: number;
index: number;
status: 'Available' | 'Occupied' | 'Reserved' | 'Unavailable' | 'Faulted'
}
]
}
export type ChargePointThumb = {
id: string;
identity: string;
price: number;
}

View file

@ -0,0 +1,14 @@
import type { Transaction } from "./transaction";
export type Dashboard = {
stats: {
transaction_count: number;
transaction_count_previous: number;
transaction_energy_total: number;
transaction_energy_total_previous: number;
transaction_cost_total: number;
transaction_cost_total_previous: number;
};
current_transaction: Transaction | undefined;
recent_transactions: Transaction[];
}

View file

@ -0,0 +1,17 @@
import type { ChargePointThumb } from "./chargepoint";
import type { UserThumb } from "./user";
export type TransactionEventTriggerReason = 'Authorized' | 'CablePluggedIn' | 'ChargingRateChanged' | 'ChargingStateChanged' | 'Deauthorized' | 'EnergyLimitReached' | 'EVCommunicationLost' | 'EVConnectTimeout' | 'MeterValueClock' | 'MeterValuePeriodic' | 'TimeLimitReached' | 'Trigger' | 'UnlockCommand' | 'StopAuthorized' | 'EVDeparted' | 'EVDetected' | 'RemoteStop' | 'RemoteStart' | 'AbnormalCondition' | 'SignedDataReceived' | 'ResetCommand';
export type Transaction = {
id: string;
status: 'ongoing' | 'ended';
started_at: string;
ended_at: string | undefined;
meter_start: number;
meter_end: number;
end_reason: TransactionEventTriggerReason;
price: number;
user: UserThumb;
chargepoint: ChargePointThumb;
}

View file

@ -0,0 +1,4 @@
export type UserThumb = {
id: string;
friendly_name: string;
}

29
frontend/src/lib/util.ts Normal file
View file

@ -0,0 +1,29 @@
export const currentDaytime = function(): string {
const currentHour = new Date().getHours()
if (currentHour >= 0 && currentHour < 12) {
return 'morning'
} else if (currentHour >= 12 && currentHour < 18) {
return 'day'
}
return 'evening'
}
export function relativeTimeFromDate(date: Date): {val: number, range: Intl.RelativeTimeFormatUnit} | undefined {
const units: {unit: Intl.RelativeTimeFormatUnit; ms: number}[] = [
{unit: "year", ms: 31536000000},
{unit: "month", ms: 2628000000},
{unit: "day", ms: 86400000},
{unit: "hour", ms: 3600000},
{unit: "minute", ms: 60000},
{unit: "second", ms: 1000},
];
const elapsed = date.getTime() - new Date().getTime();
for (const {unit, ms} of units) {
if (Math.abs(elapsed) >= ms || unit === "second") {
return {val: Math.round(elapsed / ms), range: unit};
}
}
return undefined;
}

View file

@ -0,0 +1,99 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { persistentSettings } from '$lib/persistent_store'
import i18n from '$lib/i18n'
import { logout } from '$lib/axios.svelte'
let { children } = $props()
if (!$persistentSettings.loggedIn) {
goto('/login')
}
let drawerOpen = $state(false)
</script>
<div class="w-full h-full p-4 flex flex-col bg-base-200">
<div class="navbar w-auto mb-4 pr-4 bg-base-100 shadow-md rounded-md">
<div class="drawer navbar-start">
<input bind:checked={drawerOpen} id="nav-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<label for="nav-drawer" class="btn btn-ghost btn-circle drawer-button">
<i class="bi bi-list text-2xl"></i>
</label>
</div>
<div class="drawer-side z-10">
<label for="nav-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu bg-base-100 text-base min-h-full w-80 p-4">
<li class="mb-4">
<a class="btn btn-ghost text-xl" href="/">LibreCharge</a>
</li>
<li>
<a
onclick={() => {
drawerOpen = !drawerOpen
}}
href="/"
>
<i class="bi bi-graph-up text-xl"></i>
<span>Dashboard</span>
</a>
</li>
<li>
<a
onclick={() => {
drawerOpen = !drawerOpen
}}
href="/idtoken"
>
<i class="bi bi-credit-card-fill text-xl"></i>
<span>{$i18n.t('common:navbar.link.idtoken')}</span>
</a>
</li>
<li>
<a
onclick={() => {
drawerOpen = !drawerOpen
}}
href="/transaction"
>
<i class="bi bi-battery-charging text-xl"></i>
<span>{$i18n.t('common:navbar.link.transaction')}</span>
</a>
</li>
<li>
<a
onclick={() => {
drawerOpen = !drawerOpen
}}
href="/chargepoint"
>
<i class="bi bi-plug-fill text-xl"></i>
<span>{$i18n.t('common:navbar.link.chargepoint')}</span>
</a>
</li>
</ul>
</div>
</div>
<div class="navbar-center">
<a class="btn btn-ghost text-xl" href="/">LibreCharge</a>
</div>
<div class="navbar-end">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
<i class="bi bi-person-circle text-2xl"></i>
</div>
<ul
tabindex="-1"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
>
<li><a href="/profile">{$i18n.t('common:navbar.link.profile')}</a></li>
<li><button onclick={logout}>{$i18n.t('common:navbar.link.logout')}</button></li>
</ul>
</div>
</div>
</div>
<div class="w-full h-full flex flex-col">
{@render children()}
</div>
</div>

View file

@ -0,0 +1,120 @@
<script lang="ts">
import { persistentSettings } from '$lib/persistent_store'
import { currentDaytime } from '$lib/util'
import i18n from '$lib/i18n'
import DashboardCard from '$lib/component/DashboardCard.svelte'
import TransactionTable from '$lib/component/TransactionTable.svelte'
import type { Dashboard } from '$lib/types/dashboard'
import axios from '$lib/axios.svelte'
import { onMount } from 'svelte'
$i18n.loadNamespaces('dashboard')
let dashboardData: Dashboard = $state({
stats: {
transaction_count: 0,
transaction_count_previous: 0,
transaction_energy_total: 0,
transaction_energy_total_previous: 0,
transaction_cost_total: 0,
transaction_cost_total_previous: 0,
},
current_transaction: undefined,
recent_transactions: [],
})
function refreshDashboard() {
axios.get('/me/dashboard').then((res) => {
dashboardData = res.data
})
}
onMount(() => {
refreshDashboard()
const interval = setInterval(() => {
refreshDashboard()
}, 15000)
return () => clearInterval(interval)
})
</script>
<div class="w-full h-full mt-10 flex flex-col">
<p class="w-full text-2xl font-bold">
{$i18n.t('dashboard:greeting.' + currentDaytime(), { name: $persistentSettings.friendlyName })}
</p>
<div class="w-full mt-5 flex gap-x-4">
<div class="card bg-base-100 shadow rounded-md basis-0 grow">
<div class="card-body gap-2">
<div class="flex items-start justify-between gap-2 text-sm">
<div>
<p class="text-base-content/80 font-medium">
{$i18n.t('dashboard:cards.currentTransaction')}
</p>
<div class="mt-3 flex items-center gap-2">
{#if dashboardData.current_transaction}
<div class="inline-grid *:[grid-area:1/1]">
<div class="status status-success animate-ping"></div>
<div class="status status-success"></div>
</div>
{/if}
<p class="text-2xl font-semibold">
{#if dashboardData.current_transaction}{dashboardData.current_transaction
.meter_end - dashboardData.current_transaction.meter_start} kWh{:else}-{/if}
</p>
{#if dashboardData.current_transaction}
<div class="badge badge-soft badge-success badge-sm gap-0.5 px-1 font-medium">
{(dashboardData.current_transaction.meter_end -
dashboardData.current_transaction.meter_start) *
dashboardData.current_transaction.price} €
</div>
{/if}
</div>
</div>
<i class="bi bi-plug-fill text-primary text-4xl"></i>
</div>
<div class="w-full flex flex-row items-center">
<p class="text-base-content/60 text-sm">
{#if dashboardData.current_transaction}
{$i18n.t('dashboard:cards.chargepoint', {
name: dashboardData.current_transaction.chargepoint.identity,
})}
{:else}
{$i18n.t('dashboard:cards.noCurrentTransaction')}
{/if}
</p>
{#if dashboardData.current_transaction}
<button class="btn btn-xs btn-primary">
{$i18n.t('dashboard:cards.toCurrentTransactionButton')}
<i class="bi bi-arrow-right"></i>
</button>
{/if}
</div>
</div>
</div>
<DashboardCard
title={$i18n.t('dashboard:cards.transactionCount')}
icon={'bi-battery-charging'}
value={dashboardData.stats.transaction_count}
previousValue={dashboardData.stats.transaction_count_previous}
/>
<DashboardCard
title={$i18n.t('dashboard:cards.transactionEnergyTotal')}
icon={'bi-lightning-charge-fill'}
value={+dashboardData.stats.transaction_energy_total.toFixed(2)}
previousValue={+dashboardData.stats.transaction_energy_total_previous.toFixed(2)}
unit={'kWh'}
/>
<DashboardCard
title={$i18n.t('dashboard:cards.transactionCostTotal')}
icon={'bi-currency-exchange'}
value={+dashboardData.stats.transaction_cost_total.toFixed(2)}
previousValue={+dashboardData.stats.transaction_cost_total_previous.toFixed(2)}
unit={'€'}
/>
</div>
<div class="w-full flex flex-col mt-10">
<p class="text-xl font-bold">{$i18n.t('dashboard:table.title')}</p>
<TransactionTable transactions={dashboardData.recent_transactions} />
</div>
</div>

View file

@ -0,0 +1,106 @@
<script lang="ts">
import axios from '$lib/axios.svelte'
import i18n from '$lib/i18n'
import type { ChargePoint } from '$lib/types/chargepoint'
import { relativeTimeFromDate } from '$lib/util'
import { onMount } from 'svelte'
$i18n.loadNamespaces('chargepoint')
let chargepoints: ChargePoint[] = $state([])
const pageSize = 25
let page: number = $state(1)
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has('page')) {
page = Number.parseInt(urlParams.get('page')!)
}
function load() {
axios
.get('/chargepoints', { params: { skip: (page - 1) * pageSize, limit: pageSize } })
.then((res) => {
chargepoints = res.data
})
}
function nextPage() {
++page
load()
}
function previousPage() {
--page
load()
}
onMount(() => {
load()
})
</script>
<div class="w-full h-full mt-4 flex flex-col">
<p class="w-full text-2xl font-bold">
{$i18n.t('chargepoint:header')}
</p>
<div class="overflow-x-auto mt-8 bg-base-100 rounded-md">
<table class="table">
<thead>
<tr>
<th>{$i18n.t('chargepoint:tableHeader.name')}</th>
<th>{$i18n.t('chargepoint:tableHeader.active')}</th>
<th>{$i18n.t('chargepoint:tableHeader.lastSeen')}</th>
<th>{$i18n.t('chargepoint:tableHeader.price')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#if chargepoints.length > 0}
{#each chargepoints as chargepoint}
<tr>
<td>
{chargepoint.identity}
</td>
<td>
{#if chargepoint.is_active}
{$i18n.t('chargepoint:state.enabled')}
{:else}
{$i18n.t('chargepoint:state.disabled')}
{/if}
</td>
<td>
{$i18n.t(
'chargepoint:tableFormatting.lastSeen',
relativeTimeFromDate(new Date(chargepoint.last_seen))!
)}
</td>
<td>{chargepoint.price}</td>
<th>
<a href="/chargepoint/{chargepoint.id}" class="btn btn-sm btn-primary">
{$i18n.t('common:transactionTable.detailButton')}
<i class="bi bi-arrow-right"></i>
</a>
</th>
</tr>
{/each}
{/if}
</tbody>
</table>
{#if chargepoints.length === 0}
<p class="w-full mt-15 mb-15 text-center text-xl font-bold">
{$i18n.t('chargepoint:noChargepoints')}
</p>
{/if}
</div>
<div class="join mt-4">
<button onclick={previousPage} disabled={page === 1 || null} class="join-item btn bg-base-100"
</button
>
<button class="join-item btn bg-base-100">Page {page}</button>
<button
onclick={nextPage}
disabled={chargepoints.length < pageSize || null}
class="join-item btn bg-base-100">»</button
>
</div>
</div>

View file

@ -0,0 +1,38 @@
<script>
import { persistentSettings } from '$lib/persistent_store'
let { children } = $props()
import '../app.css'
</script>
<div
class="w-screen h-screen flex flex-col"
data-theme={$persistentSettings.darkmode ? 'darkgray' : 'emerald'}
>
{@render children()}
<footer
class="footer sm:footer-horizontal bg-neutral text-neutral-content items-center pt-3 pb-3 pl-6 pr-6"
>
<aside class="grid-flow-col">
<p>Powered by LibreCharge - Licensed under GNU AGPL v3</p>
</aside>
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end text-3xl">
<label class="swap swap-rotate">
<!-- this hidden checkbox controls the state -->
<input type="checkbox" bind:checked={$persistentSettings.darkmode} />
<!-- sun icon -->
<i class="bi bi-sun swap-off"></i>
<!-- moon icon -->
<i class="bi bi-moon swap-on"></i>
</label>
<a
aria-label="Source Code"
href="https://git.bluemedia.dev/Bluemedia/simple-ocpp-cs"
target="_blank"
>
<i class="bi bi-git"></i>
</a>
</nav>
</footer>
</div>

View file

@ -1,6 +0,0 @@
<script>
import '../style.css'
</script>
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>

View file

@ -0,0 +1,91 @@
<script lang="ts">
import { fly } from 'svelte/transition'
import i18n from '$lib/i18n'
import { login as performLogin } from '$lib/axios.svelte'
$i18n.loadNamespaces('login')
type ToastType = 'reauth' | 'logout' | 'error'
let toastType: ToastType = $state('reauth')
let toastVisible = $state(false)
function showToast(type: ToastType) {
toastType = type
toastVisible = true
setTimeout(() => {
toastVisible = false
}, 6000)
}
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has('reauth')) {
showToast('reauth')
}
if (urlParams.has('logout')) {
showToast('logout')
}
let email: string = $state('')
let password: string = $state('')
async function login() {
await performLogin(email, password).catch(() => {
showToast('error')
})
}
</script>
<div class="w-full h-full flex flex-col justify-center items-center bg-base-200">
<div class="basis-0 grow flex justify-center items-center">
<div class="flex flex-col items-center justify-center">
<i class="bi bi-ev-front-fill text-8xl text-primary"></i>
<p class="text-2xl font-bold">LibreCharge</p>
</div>
</div>
<div class="basis-0 flex justify-center items-center">
<div class="card bg-base-100 w-full max-w-sm h-fit shrink-0 shadow-2xl">
<div class="card-body">
<h1 class="text-2xl font-bold">{$i18n.t('login:title')}</h1>
<p class="py-2 text-base">{$i18n.t('login:text')}</p>
<form onsubmit={login}>
<fieldset class="fieldset">
<label class="fieldset-label input text-base-content">
<i class="bi bi-envelope-at"></i>
<input type="email" bind:value={email} placeholder="me@example.com" required />
</label>
<label class="fieldset-label input text-base-content">
<i class="bi bi-key"></i>
<input
type="password"
bind:value={password}
placeholder={$i18n.t('login:passwordPlaceholder')}
required
/>
</label>
<input type="submit" class="btn btn-primary mt-4" value={$i18n.t('login:button')} />
</fieldset>
</form>
</div>
</div>
</div>
<span class="basis-0 grow"></span>
{#if toastVisible}
<div class="toast toast-top" out:fly={{ x: 200 }}>
{#if toastType === 'reauth'}
<div class="alert text-base alert-warning">
<i class="bi bi-exclamation-diamond text-xl"></i>
<span>{$i18n.t('login:toast.reauth')}</span>
</div>
{:else if toastType === 'logout'}
<div class="alert text-base alert-success">
<i class="bi bi-check-circle text-xl"></i>
<span>{$i18n.t('login:toast.logoutSuccess')}</span>
</div>
{:else if toastType === 'error'}
<div class="alert text-base alert-error">
<i class="bi bi-x-circle text-xl"></i>
<span>{$i18n.t('login:toast.loginFailed')}</span>
</div>
{/if}
</div>
{/if}
</div>

View file

@ -1,6 +0,0 @@
@import 'tailwindcss';
@plugin "daisyui" {
themes:
light --default,
dark --prefersdark;
}

View file

@ -0,0 +1,17 @@
{
"header": "Ladepunkte",
"tableHeader": {
"name": "Name",
"active": "Aktiv",
"price": "Preis",
"lastSeen": "Zuletzt gesehen"
},
"tableFormatting": {
"lastSeen": "{{val, relativetime}}"
},
"state": {
"enabled": "Aktiv",
"disabled": "Deaktiviert"
},
"noChargepoints": "Noch keine Ladepunkte vorhanden."
}

View file

@ -0,0 +1,21 @@
{
"navbar": {
"link": {
"idtoken": "Ladekarten",
"transaction": "Ladevorgänge",
"chargepoint": "Ladestationen",
"profile": "Profil",
"logout": "Abmelden"
}
},
"transactionTable": {
"noPreviousTransactions": "Du hast noch keine abgeschlossenen Ladevorgänge.",
"headerDate": "Zeitpunkt",
"headerChargepoint": "Ladestation",
"headerEnergyTotal": "Ladevolumen",
"headerCost": "Kosten",
"startTime": "Beginn: {{ time, datetime }} Uhr",
"endTime": "Ende: {{ time, datetime }} Uhr",
"detailButton": "Details"
}
}

View file

@ -0,0 +1,28 @@
{
"greeting": {
"morning": "Guten Morgen {{name}}!",
"day": "Guten Tag {{name}}!",
"evening": "Guten Abend {{name}}!"
},
"cards": {
"currentTransaction": "Aktueller Ladevorgang",
"chargepoint": "Ladestation: {{name}}",
"toCurrentTransactionButton": "Zum Ladevorgang",
"noCurrentTransaction": "Kein Ladevorgang aktiv",
"transactionCount": "Ladevorgänge",
"transactionEnergyTotal": "Ladevolumen",
"transactionCostTotal": "Kosten",
"lastMonth": "{{val}} {{unit}} im letzten Monat"
},
"table": {
"title": "Deine letzten Ladevorgänge",
"noPreviousTransactions": "Du hast noch keine abgeschlossenen Ladevorgänge.",
"headerDate": "Zeitpunkt",
"headerChargepoint": "Ladestation",
"headerEnergyTotal": "Ladevolumen",
"headerCost": "Kosten",
"startTime": "Beginn: {{ time, datetime }} Uhr",
"endTime": "Ende: {{ time, datetime }} Uhr",
"detailButton": "Details"
}
}

View file

@ -0,0 +1,11 @@
{
"title": "Anmelden",
"text": "Bitte melde dich mit deinem Account an:",
"passwordPlaceholder": "Passwort",
"button": "Anmelden",
"toast": {
"reauth": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
"logoutSuccess": "Abmeldung erfolgreich.",
"loginFailed": "Anmeldung fehlgeschlagen."
}
}

View file

@ -0,0 +1,17 @@
{
"header": "Chargepoints",
"tableHeader": {
"name": "Name",
"active": "Active",
"price": "Price",
"lastSeen": "Last seen"
},
"tableFormatting": {
"lastSeen": "{{val, relativetime}}"
},
"state": {
"enabled": "Active",
"disabled": "Disabled"
},
"noChargepoints": "There are no chargepoints yet."
}

View file

@ -0,0 +1,21 @@
{
"navbar": {
"link": {
"idtoken": "Charging Cards",
"transaction": "Transactions",
"chargepoint": "Charging Stations",
"profile": "Profile",
"logout": "Logout"
}
},
"transactionTable": {
"noPreviousTransactions": "You don't have any completed transactions yet.",
"headerDate": "Date",
"headerChargepoint": "Chargepoint",
"headerEnergyTotal": "Energy charged",
"headerCost": "Cost",
"startTime": "Start: {{ time, datetime }}",
"endTime": "End: {{ time, datetime }}",
"detailButton": "Details"
}
}

View file

@ -0,0 +1,28 @@
{
"greeting": {
"morning": "Good morning {{name}}!",
"day": "Hello {{name}}!",
"evening": "Good evening {{name}}!"
},
"cards": {
"currentTransaction": "Current Transaction",
"chargepoint": "Chargepoint: {{name}}",
"toCurrentTransactionButton": "More",
"noCurrentTransaction": "No active transaction",
"transactionCount": "Transactions",
"transactionEnergyTotal": "Energy Ammount",
"transactionCostTotal": "Cost",
"lastMonth": "{{val}} {{unit}} last month"
},
"table": {
"title": "Your recent transactions",
"noPreviousTransactions": "You don't have any completed transactions yet.",
"headerDate": "Date",
"headerChargepoint": "Chargepoint",
"headerEnergyTotal": "Energy charged",
"headerCost": "Cost",
"startTime": "Start: {{ time, datetime }}",
"endTime": "End: {{ time, datetime }}",
"detailButton": "Details"
}
}

View file

@ -0,0 +1,11 @@
{
"title": "Login",
"text": "Please login to your account:",
"passwordPlaceholder": "Password",
"button": "Login",
"toast": {
"reauth": "Your session expired. Please log in again.",
"logoutSuccess": "Successfully logged out.",
"loginFailed": "Login failed."
}
}