Compare commits
11 commits
main
...
feature/fr
Author | SHA1 | Date | |
---|---|---|---|
dae1d7b9b8 | |||
5fa13de642 | |||
1c810e6293 | |||
8e42205301 | |||
4272f2878e | |||
791a79249c | |||
ceb337b87c | |||
6f91d008a1 | |||
cb3a151748 | |||
0398acf2b7 | |||
aa9357063f |
55 changed files with 2151 additions and 499 deletions
|
@ -1,5 +1,6 @@
|
|||
from dotenv import load_dotenv
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
|
||||
load_dotenv()
|
||||
|
@ -47,6 +48,19 @@ def create_app():
|
|||
app.include_router(api_v1_router)
|
||||
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
|
||||
|
||||
app = create_app()
|
||||
|
|
|
@ -25,3 +25,4 @@ class ChargePoint(Base):
|
|||
connectors = relationship("Connector", cascade="delete, delete-orphan")
|
||||
transactions = relationship("Transaction", cascade="delete, delete-orphan")
|
||||
variables = relationship("ChargepointVariable", cascade="delete, delete-orphan")
|
||||
firmware_updates = relationship("FirmwareUpdate", cascade="delete, delete-orphan")
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import uuid
|
||||
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, Uuid
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
from app.schemas.firmware_update import FirmwareUpdateStatus
|
||||
|
@ -20,3 +21,4 @@ class FirmwareUpdate(Base):
|
|||
signature = Column(String, nullable=True)
|
||||
|
||||
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True)
|
||||
chargepoint = relationship("ChargePoint", back_populates="firmware_updates")
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import uuid
|
||||
from sqlalchemy import Uuid, Column, DateTime, Enum, Float, ForeignKey, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
from app.schemas.meter_value import Measurand, PhaseType
|
||||
|
@ -15,3 +16,4 @@ class MeterValue(Base):
|
|||
value = Column(Float)
|
||||
|
||||
transaction_id = Column(String, ForeignKey("transactions.id"), index=True)
|
||||
transaction = relationship("Transaction", back_populates="meter_values")
|
|
@ -1,5 +1,6 @@
|
|||
import uuid
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, String, Uuid
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
@ -12,3 +13,4 @@ class Session(Base):
|
|||
last_used = Column(DateTime(timezone=True))
|
||||
|
||||
user_id = Column(Uuid, ForeignKey("users.id"), nullable=False, index=True)
|
||||
user = relationship("User", back_populates="sessions")
|
|
@ -1,4 +1,5 @@
|
|||
from sqlalchemy import String, Uuid, Column, DateTime, Enum, Numeric, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.schemas.transaction import TransactionEventTriggerReason, TransactionStatus
|
||||
from app.database import Base
|
||||
|
@ -11,9 +12,14 @@ class Transaction(Base):
|
|||
started_at = Column(DateTime, index=True)
|
||||
ended_at = Column(DateTime, nullable=True, index=True)
|
||||
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)
|
||||
price = Column(Numeric(10,2))
|
||||
|
||||
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 = relationship("ChargePoint", back_populates="transactions")
|
||||
|
||||
meter_values = relationship("MeterValue", cascade="delete, delete-orphan")
|
||||
|
|
|
@ -17,3 +17,4 @@ class User(Base):
|
|||
|
||||
id_tokens = relationship("IdToken", back_populates="owner", cascade="delete, delete-orphan")
|
||||
transactions = relationship("Transaction", cascade="delete, delete-orphan")
|
||||
sessions = relationship("Session", cascade="delete, delete-orphan")
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.params import Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
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.services import session_service, user_service
|
||||
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)"])
|
||||
|
||||
|
@ -109,3 +114,74 @@ async def delete_user_session(
|
|||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
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
|
||||
)
|
||||
|
|
|
@ -3,6 +3,7 @@ from datetime import datetime
|
|||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.user import Role
|
||||
from app.util.encoders import force_utc_datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -19,3 +20,6 @@ class TokenResponse(BaseModel):
|
|||
access_token: str
|
||||
refresh_token: str
|
||||
not_after: datetime
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: force_utc_datetime}
|
||||
|
|
|
@ -8,6 +8,8 @@ from app.schemas.connector import Connector
|
|||
|
||||
from ocpp.v201.enums import ResetEnumType, ResetStatusEnumType
|
||||
|
||||
from app.util.encoders import decimal_encoder, force_utc_datetime
|
||||
|
||||
class ChargePointBase(BaseModel):
|
||||
identity: str
|
||||
is_active: bool
|
||||
|
@ -32,6 +34,16 @@ class ChargePoint(ChargePointBase):
|
|||
|
||||
class Config:
|
||||
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):
|
||||
password: str
|
||||
|
|
|
@ -4,6 +4,8 @@ from uuid import UUID
|
|||
from pydantic import BaseModel
|
||||
import enum
|
||||
|
||||
from app.util.encoders import decimal_encoder
|
||||
|
||||
class AttributeType(enum.Enum):
|
||||
ACTUAL = "Actual"
|
||||
TARGET = "Target"
|
||||
|
@ -52,6 +54,7 @@ class ChargepointVariable(BaseModel):
|
|||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_encoders = {Decimal: decimal_encoder}
|
||||
|
||||
class ChargepointVariableUpdate(BaseModel):
|
||||
value: str
|
||||
|
|
25
backend/app/schemas/dashboard.py
Normal file
25
backend/app/schemas/dashboard.py
Normal 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}
|
|
@ -5,6 +5,8 @@ from typing import Optional
|
|||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.util.encoders import decimal_encoder, force_utc_datetime
|
||||
|
||||
class PhaseType(enum.Enum):
|
||||
L1 = "L1"
|
||||
L2 = "L2"
|
||||
|
@ -55,3 +57,4 @@ class MeterValue(BaseModel):
|
|||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_encoders = {Decimal: decimal_encoder, datetime: force_utc_datetime}
|
||||
|
|
|
@ -2,10 +2,14 @@ from datetime import datetime
|
|||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.util.encoders import force_utc_datetime
|
||||
|
||||
|
||||
class Session(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
last_used: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_encoders = {datetime: force_utc_datetime}
|
|
@ -1,10 +1,13 @@
|
|||
import enum
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
import enum
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
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):
|
||||
ONGOING = "ongoing"
|
||||
ENDED = "ended"
|
||||
|
@ -42,14 +45,15 @@ class Transaction(BaseModel):
|
|||
started_at: datetime
|
||||
ended_at: Optional[datetime] = None
|
||||
meter_start: Decimal
|
||||
meter_end: Optional[Decimal] = None
|
||||
meter_end: Decimal
|
||||
end_reason: Optional[TransactionEventTriggerReason] = None
|
||||
price: Decimal
|
||||
user_id: Optional[UUID] = None
|
||||
chargepoint_id: UUID
|
||||
user: UserThumb
|
||||
chargepoint: ChargePointThumb
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_encoders = {Decimal: decimal_encoder, datetime: force_utc_datetime}
|
||||
|
||||
class RemoteTransactionStartStopResponse(BaseModel):
|
||||
status: RemoteTransactionStartStopStatus
|
||||
|
|
|
@ -30,6 +30,13 @@ class User(UserBase):
|
|||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class UserThumb(BaseModel):
|
||||
id: UUID
|
||||
friendly_name: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class PasswordUpdate(BaseModel):
|
||||
old_password: str = Field(max_length=100)
|
||||
new_password: str = Field(max_length=100)
|
||||
|
|
|
@ -28,7 +28,7 @@ class JWTBearer(HTTPBearer):
|
|||
if credentials:
|
||||
if not credentials.scheme == "Bearer":
|
||||
raise HTTPException(
|
||||
status_code=403, detail="authentication_scheme_invalid"
|
||||
status_code=401, detail="authentication_scheme_invalid"
|
||||
)
|
||||
try:
|
||||
token = await token_service.verify_access_token(
|
||||
|
@ -36,7 +36,7 @@ class JWTBearer(HTTPBearer):
|
|||
)
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="token_invalid_or_expired"
|
||||
status_code=401, detail="token_invalid_or_expired"
|
||||
)
|
||||
return token
|
||||
except InsufficientPermissionsError:
|
||||
|
@ -44,4 +44,4 @@ class JWTBearer(HTTPBearer):
|
|||
except InvalidTokenAudienceError:
|
||||
raise HTTPException(status_code=403, detail="invalid_token_audience")
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="authorization_code_invalid")
|
||||
raise HTTPException(status_code=401, detail="authorization_code_invalid")
|
|
@ -21,19 +21,23 @@ async def create_transaction(
|
|||
with SessionLocal() as db:
|
||||
chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
|
||||
meter_start=0
|
||||
meter_end=0
|
||||
if "meter_value" in transaction_data.keys():
|
||||
for meter_value_entry in transaction_data['meter_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):
|
||||
meter_start = sampled_value['value']
|
||||
meter_end = sampled_value['value']
|
||||
else:
|
||||
meter_start = sampled_value['value']
|
||||
meter_end = sampled_value['value']
|
||||
transaction = Transaction(
|
||||
id=transaction_info["transaction_id"],
|
||||
status=TransactionStatus.ONGOING,
|
||||
started_at=timestamp,
|
||||
meter_start=meter_start,
|
||||
meter_end=meter_end,
|
||||
price=chargepoint.price,
|
||||
chargepoint_id=chargepoint.id,
|
||||
user_id=user_id
|
||||
|
@ -55,6 +59,12 @@ async def update_transaction(
|
|||
transaction_id=transaction.id,
|
||||
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(
|
||||
transaction_id: str,
|
||||
|
|
27
backend/app/util/encoders.py
Normal file
27
backend/app/util/encoders.py
Normal 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)
|
1278
frontend/package-lock.json
generated
1278
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -34,7 +34,14 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
"axios": "^1.8.4",
|
||||
"bootstrap-icons": "^1.11.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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,5 +13,17 @@
|
|||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2,
|
||||
"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
42
frontend/src/app.css
Normal 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;
|
||||
}
|
108
frontend/src/lib/axios.svelte.ts
Normal file
108
frontend/src/lib/axios.svelte.ts
Normal 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;
|
51
frontend/src/lib/component/DashboardCard.svelte
Normal file
51
frontend/src/lib/component/DashboardCard.svelte
Normal 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>
|
89
frontend/src/lib/component/TransactionTable.svelte
Normal file
89
frontend/src/lib/component/TransactionTable.svelte
Normal 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
32
frontend/src/lib/i18n.ts
Normal 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
|
55
frontend/src/lib/i18n_store.ts
Normal file
55
frontend/src/lib/i18n_store.ts
Normal 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
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
37
frontend/src/lib/persistent_store.ts
Normal file
37
frontend/src/lib/persistent_store.ts
Normal 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;
|
||||
})
|
||||
}
|
25
frontend/src/lib/types/chargepoint.ts
Normal file
25
frontend/src/lib/types/chargepoint.ts
Normal 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;
|
||||
}
|
14
frontend/src/lib/types/dashboard.ts
Normal file
14
frontend/src/lib/types/dashboard.ts
Normal 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[];
|
||||
}
|
17
frontend/src/lib/types/transaction.ts
Normal file
17
frontend/src/lib/types/transaction.ts
Normal 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;
|
||||
}
|
4
frontend/src/lib/types/user.ts
Normal file
4
frontend/src/lib/types/user.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export type UserThumb = {
|
||||
id: string;
|
||||
friendly_name: string;
|
||||
}
|
29
frontend/src/lib/util.ts
Normal file
29
frontend/src/lib/util.ts
Normal 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;
|
||||
}
|
99
frontend/src/routes/(navbar)/+layout.svelte
Normal file
99
frontend/src/routes/(navbar)/+layout.svelte
Normal 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>
|
120
frontend/src/routes/(navbar)/+page.svelte
Normal file
120
frontend/src/routes/(navbar)/+page.svelte
Normal 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>
|
106
frontend/src/routes/(navbar)/chargepoint/+page.svelte
Normal file
106
frontend/src/routes/(navbar)/chargepoint/+page.svelte
Normal 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>
|
0
frontend/src/routes/(navbar)/idtoken/+page.svelte
Normal file
0
frontend/src/routes/(navbar)/idtoken/+page.svelte
Normal file
0
frontend/src/routes/(navbar)/profile/+page.svelte
Normal file
0
frontend/src/routes/(navbar)/profile/+page.svelte
Normal file
0
frontend/src/routes/(navbar)/transaction/+page.svelte
Normal file
0
frontend/src/routes/(navbar)/transaction/+page.svelte
Normal file
38
frontend/src/routes/+layout.svelte
Normal file
38
frontend/src/routes/+layout.svelte
Normal 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>
|
|
@ -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>
|
91
frontend/src/routes/login/+page.svelte
Normal file
91
frontend/src/routes/login/+page.svelte
Normal 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>
|
|
@ -1,6 +0,0 @@
|
|||
@import 'tailwindcss';
|
||||
@plugin "daisyui" {
|
||||
themes:
|
||||
light --default,
|
||||
dark --prefersdark;
|
||||
}
|
17
frontend/static/locales/de/chargepoint.json
Normal file
17
frontend/static/locales/de/chargepoint.json
Normal 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."
|
||||
}
|
21
frontend/static/locales/de/common.json
Normal file
21
frontend/static/locales/de/common.json
Normal 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"
|
||||
}
|
||||
}
|
28
frontend/static/locales/de/dashboard.json
Normal file
28
frontend/static/locales/de/dashboard.json
Normal 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"
|
||||
}
|
||||
}
|
11
frontend/static/locales/de/login.json
Normal file
11
frontend/static/locales/de/login.json
Normal 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."
|
||||
}
|
||||
}
|
17
frontend/static/locales/en/chargepoint.json
Normal file
17
frontend/static/locales/en/chargepoint.json
Normal 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."
|
||||
}
|
21
frontend/static/locales/en/common.json
Normal file
21
frontend/static/locales/en/common.json
Normal 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"
|
||||
}
|
||||
}
|
28
frontend/static/locales/en/dashboard.json
Normal file
28
frontend/static/locales/en/dashboard.json
Normal 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"
|
||||
}
|
||||
}
|
11
frontend/static/locales/en/login.json
Normal file
11
frontend/static/locales/en/login.json
Normal 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."
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue