Initial commit: Angular PV Pulse App

This commit is contained in:
2026-03-10 11:29:29 +01:00
commit 90d8cb78fd
69 changed files with 11966 additions and 0 deletions

19
src/app/app.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { apiInterceptor } from './core/interceptors/api.interceptor';
import { mockApiInterceptor } from './core/interceptors/mock-api.interceptor';
import { AuthService } from './core/services/auth.service';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
// Beide Interceptors immer aktiv mockApiInterceptor prüft useMocks zur Laufzeit
provideHttpClient(withInterceptors([mockApiInterceptor, apiInterceptor])),
// Beim App-Start automatisch einloggen wenn Zugangsdaten hinterlegt sind
provideAppInitializer(() => inject(AuthService).initialize()),
],
};

10
src/app/app.html Normal file
View File

@@ -0,0 +1,10 @@
<div class="app-shell">
<app-header />
<div class="app-body">
<app-sidebar />
<main class="app-content">
<router-outlet />
</main>
</div>
<app-footer />
</div>

64
src/app/app.routes.ts Normal file
View File

@@ -0,0 +1,64 @@
import { Routes } from '@angular/router';
import { settingsGuard } from './core/guards/settings.guard';
export const routes: Routes = [
{
path: '',
redirectTo: 'dashboard',
pathMatch: 'full',
},
{
path: 'dashboard',
loadComponent: () =>
import('./features/dashboard/dashboard.component').then(
(m) => m.DashboardComponent
),
},
{
path: 'plants',
loadComponent: () =>
import('./features/plant-list/plant-list.component').then(
(m) => m.PlantListComponent
),
},
{
path: 'plants/:id',
loadComponent: () =>
import('./features/plant-shell/plant-shell.component').then(
(m) => m.PlantShellComponent
),
children: [
{
path: '',
redirectTo: 'overview',
pathMatch: 'full',
},
{
path: 'overview',
loadComponent: () =>
import('./features/plant-detail/plant-detail.component').then(
(m) => m.PlantDetailComponent
),
},
{
path: 'history',
loadComponent: () =>
import('./features/plant-history/plant-history.component').then(
(m) => m.PlantHistoryComponent
),
},
],
},
{
path: 'settings',
canActivate: [settingsGuard],
loadComponent: () =>
import('./features/settings/settings.component').then(
(m) => m.SettingsComponent
),
},
{
path: '**',
redirectTo: 'dashboard',
},
];

16
src/app/app.scss Normal file
View File

@@ -0,0 +1,16 @@
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-body {
display: flex;
flex: 1;
}
.app-content {
flex: 1;
background: #f8f9fa;
overflow-y: auto;
}

23
src/app/app.spec.ts Normal file
View File

@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, pv-pulse-internorm');
});
});

14
src/app/app.ts Normal file
View File

@@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { HeaderComponent } from './layout/header/header.component';
import { SidebarComponent } from './layout/sidebar/sidebar.component';
import { FooterComponent } from './layout/footer/footer.component';
@Component({
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, HeaderComponent, SidebarComponent, FooterComponent],
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {}

View File

@@ -0,0 +1,18 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { SettingsService } from '../services/settings.service';
/**
* Erlaubt den Zugriff auf /settings nur wenn der PIN in dieser Session
* bereits korrekt eingegeben wurde. Andernfalls Weiterleitung zum Dashboard.
*/
export const settingsGuard: CanActivateFn = () => {
const settingsService = inject(SettingsService);
const router = inject(Router);
if (settingsService.isPinVerified) {
return true;
}
return router.createUrlTree(['/dashboard']);
};

View File

@@ -0,0 +1,27 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { SettingsService } from '../services/settings.service';
/** Hängt automatisch den Bearer-Token an alle API-Requests. */
export const apiInterceptor: HttpInterceptorFn = (req, next) => {
const settingsService = inject(SettingsService);
const { apiBaseUrl } = settingsService.settings();
// Nur Requests zur eigenen API modifizieren
if (!req.url.startsWith(apiBaseUrl)) {
return next(req);
}
const token = localStorage.getItem('pv_access_token');
const modified = token
? req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
: req;
return next(modified);
};

View File

@@ -0,0 +1,70 @@
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { of, delay } from 'rxjs';
import { SettingsService } from '../services/settings.service';
import { MOCK_PLANTS } from '../mocks/mock-plants.data';
import { generateMockLiveData } from '../mocks/mock-live.data';
import { getMockHistory } from '../mocks/mock-history.data';
import { ApiResponse } from '../models/api-response.model';
/** Simulierte Netzwerk-Latenz in ms */
const MOCK_DELAY_MS = 400;
function ok<T>(data: T): HttpResponse<ApiResponse<T>> {
return new HttpResponse<ApiResponse<T>>({
status: 200,
body: { success: true, data },
});
}
function notFound(url: string): HttpResponse<unknown> {
return new HttpResponse({ status: 404, body: { success: false, message: `Nicht gefunden: ${url}` } });
}
export const mockApiInterceptor: HttpInterceptorFn = (req, next) => {
const settingsService = inject(SettingsService);
const { apiBaseUrl: base, useMocks } = settingsService.settings();
// Nur im Mock-Modus und nur API-Requests abfangen
if (!useMocks || !req.url.startsWith(base)) {
return next(req);
}
const path = req.url.replace(base, '');
// ── GET /plants ────────────────────────────────────────
if (req.method === 'GET' && path === '/plants') {
return of(ok(MOCK_PLANTS)).pipe(delay(MOCK_DELAY_MS));
}
// ── GET /plants/:id/live ───────────────────────────────
const liveMatch = path.match(/^\/plants\/([^/]+)\/live$/);
if (req.method === 'GET' && liveMatch) {
const plantId = liveMatch[1];
const plant = MOCK_PLANTS.find((p) => p.id === plantId);
if (!plant) return of(notFound(path)).pipe(delay(MOCK_DELAY_MS));
return of(ok(generateMockLiveData(plantId))).pipe(delay(MOCK_DELAY_MS));
}
// ── GET /plants/:id/history ────────────────────────────
const historyMatch = path.match(/^\/plants\/([^/]+)\/history$/);
if (req.method === 'GET' && historyMatch) {
const plantId = historyMatch[1];
const resolution = (req.params.get('resolution') ?? 'day') as 'hour' | 'day' | 'month' | 'year';
const plant = MOCK_PLANTS.find((p) => p.id === plantId);
if (!plant) return of(notFound(path)).pipe(delay(MOCK_DELAY_MS));
return of(ok(getMockHistory(plantId, resolution))).pipe(delay(MOCK_DELAY_MS));
}
// ── GET /plants/:id ────────────────────────────────────
const plantMatch = path.match(/^\/plants\/([^/]+)$/);
if (req.method === 'GET' && plantMatch) {
const plantId = plantMatch[1];
const plant = MOCK_PLANTS.find((p) => p.id === plantId);
if (!plant) return of(notFound(path)).pipe(delay(MOCK_DELAY_MS));
return of(ok(plant)).pipe(delay(MOCK_DELAY_MS));
}
// Alle anderen Requests durchleiten
return next(req);
};

View File

@@ -0,0 +1,63 @@
import { PvHistoryData, PvHistoryEntry } from '../models/pv-data.model';
type Resolution = 'hour' | 'day' | 'month' | 'year';
/** Basisertrag kWh/kWp je Zeiteinheit */
const BASE_YIELD: Record<Resolution, number> = {
hour: 0.5,
day: 3.8,
month: 90,
year: 1050,
};
/** Anzahl Datenpunkte je Auflösung */
const DATA_POINTS: Record<Resolution, number> = {
hour: 24,
day: 30,
month: 12,
year: 5,
};
/** Installierte Leistung je Anlage (kWp) für die Hochrechnung */
const CAPACITY: Record<string, number> = {
'plant-001': 420,
'plant-002': 185,
'plant-003': 98,
};
function generateEntries(plantId: string, resolution: Resolution): PvHistoryEntry[] {
const capacity = CAPACITY[plantId] ?? 200;
const baseYield = BASE_YIELD[resolution] * capacity;
const count = DATA_POINTS[resolution];
const now = new Date();
const entries: PvHistoryEntry[] = [];
for (let i = count - 1; i >= 0; i--) {
const ts = new Date(now);
switch (resolution) {
case 'hour': ts.setHours(now.getHours() - i, 0, 0, 0); break;
case 'day': ts.setDate(now.getDate() - i); ts.setHours(0, 0, 0, 0); break;
case 'month': ts.setMonth(now.getMonth() - i, 1); ts.setHours(0, 0, 0, 0); break;
case 'year': ts.setFullYear(now.getFullYear() - i, 0, 1); ts.setHours(0, 0, 0, 0); break;
}
// Simulation: Wochenenden / Nacht / Winter mit weniger Ertrag
const variance = 0.6 + Math.random() * 0.8;
const energyKwh = plantId === 'plant-004' ? 0 : parseFloat((baseYield * variance).toFixed(2));
entries.push({
timestamp: ts.toISOString(),
energyKwh,
peakPowerKw: parseFloat((capacity * (0.5 + Math.random() * 0.45)).toFixed(2)),
});
}
return entries;
}
export function getMockHistory(plantId: string, resolution: Resolution): PvHistoryData {
return {
plantId,
resolution,
entries: generateEntries(plantId, resolution),
};
}

View File

@@ -0,0 +1,34 @@
import { PvLiveData } from '../models/pv-data.model';
/** Liefert leicht variierende Livedaten je Abruf (simuliert Echtzeit-Änderungen) */
export function generateMockLiveData(plantId: string): PvLiveData {
const jitter = () => parseFloat((Math.random() * 0.1 - 0.05 + 1).toFixed(4));
const base: Record<string, Partial<PvLiveData>> = {
'plant-001': { currentPowerKw: 312.4, todayEnergyKwh: 1248.6, totalEnergyKwh: 587340, co2SavedKg: 293670, gridFeedInKw: 198.1, selfConsumptionKw: 114.3, irradianceWm2: 748, temperatureCelsius: 32, inverters: [
{ id: 'inv-1a', name: 'SMA STP 60-10 #1', powerKw: 1200.2, status: 'online', dcVoltageV: 720, acVoltageV: 400, temperatureCelsius: 41 }
]},
'plant-002': { currentPowerKw: 138.2, todayEnergyKwh: 552.8, totalEnergyKwh: 198450, co2SavedKg: 99225, gridFeedInKw: 92.5, selfConsumptionKw: 45.7, irradianceWm2: 712, temperatureCelsius: 30, inverters: [
{ id: 'inv-2a', name: 'Fronius Symo 50 #1', powerKw: 1047.1, status: 'online', dcVoltageV: 680, acVoltageV: 400, temperatureCelsius: 38 }
]},
'plant-003': { currentPowerKw: 41.5, todayEnergyKwh: 166.0, totalEnergyKwh: 52800, co2SavedKg: 26400, gridFeedInKw: 18.2, selfConsumptionKw: 23.3, irradianceWm2: 425, temperatureCelsius: 35, inverters: [
{ id: 'inv-3a', name: 'Huawei SUN2000 #1', powerKw: 1041.5, status: 'online', dcVoltageV: 640, acVoltageV: 400, temperatureCelsius: 55 }
]}
};
const data = base[plantId] ?? base['plant-001'];
return {
plantId,
timestamp: new Date().toISOString(),
currentPowerKw: parseFloat(((data.currentPowerKw ?? 0) * jitter()).toFixed(2)),
todayEnergyKwh: parseFloat(((data.todayEnergyKwh ?? 0) * jitter()).toFixed(2)),
totalEnergyKwh: data.totalEnergyKwh ?? 0,
co2SavedKg: data.co2SavedKg ?? 0,
gridFeedInKw: parseFloat(((data.gridFeedInKw ?? 0) * jitter()).toFixed(2)),
selfConsumptionKw: parseFloat(((data.selfConsumptionKw ?? 0) * jitter()).toFixed(2)),
irradianceWm2: parseFloat(((data.irradianceWm2 ?? 0) * jitter()).toFixed(1)),
temperatureCelsius: data.temperatureCelsius ?? 25,
inverters: data.inverters ?? [],
};
}

View File

@@ -0,0 +1,34 @@
import { PvPlant } from '../models/pv-plant.model';
export const MOCK_PLANTS: PvPlant[] = [
{
id: 'plant-001',
name: 'Internorm Traun',
location: 'Traun, Oberösterreich',
installedCapacityKwp: 420,
status: 'online',
installedAt: '2021-06-15',
inverterCount: 6,
moduleCount: 1050,
},
{
id: 'plant-002',
name: 'Internorm Lannach',
location: 'Lannach, Steiermark',
installedCapacityKwp: 185,
status: 'online',
installedAt: '2022-03-22',
inverterCount: 3,
moduleCount: 462,
},
{
id: 'plant-003',
name: 'Internorm Sarleinsbach',
location: 'Sarleinsbach, Oberösterreich',
installedCapacityKwp: 98,
status: 'online',
installedAt: '2023-09-01',
inverterCount: 2,
moduleCount: 245,
}
];

View File

@@ -0,0 +1,18 @@
export interface ApiResponse<T> {
data: T;
success: boolean;
message?: string;
}
export interface ApiError {
statusCode: number;
message: string;
details?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
}

View File

@@ -0,0 +1,35 @@
export interface PvLiveData {
plantId: string;
timestamp: string; // ISO date string
currentPowerKw: number;
todayEnergyKwh: number;
totalEnergyKwh: number;
co2SavedKg: number;
gridFeedInKw: number;
selfConsumptionKw: number;
irradianceWm2: number;
temperatureCelsius: number;
inverters: InverterData[];
}
export interface InverterData {
id: string;
name: string;
powerKw: number;
status: 'online' | 'offline' | 'warning';
dcVoltageV: number;
acVoltageV: number;
temperatureCelsius: number;
}
export interface PvHistoryEntry {
timestamp: string;
energyKwh: number;
peakPowerKw: number;
}
export interface PvHistoryData {
plantId: string;
resolution: 'hour' | 'day' | 'month' | 'year';
entries: PvHistoryEntry[];
}

View File

@@ -0,0 +1,13 @@
export type PlantStatus = 'online' | 'offline' | 'warning' | 'error';
export interface PvPlant {
id: string;
name: string;
location: string;
installedCapacityKwp: number;
status: PlantStatus;
installedAt: string; // ISO date string
inverterCount: number;
moduleCount: number;
imageUrl?: string;
}

View File

@@ -0,0 +1,16 @@
export interface AppSettings {
apiBaseUrl: string;
useMocks: boolean;
pollingIntervalMs: number;
/** Relativer Pfad des Auth-Endpunkts, z.B. /auth/token */
authEndpoint: string;
username: string;
/** Passwort wird für den Auto-Login beim App-Start benötigt */
password: string;
/** Endpunkt-Template für Live-Daten, z.B. /plants/{id}/live */
endpointLiveData: string;
/** Endpunkt-Template für Historien-Daten, z.B. /plants/{id}/history */
endpointHistory: string;
/** Endpunkt für die Anlagenliste, z.B. /plants */
endpointPlants: string;
}

View File

@@ -0,0 +1,72 @@
import { inject, Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { SettingsService } from './settings.service';
const TOKEN_KEY = 'pv_access_token';
interface TokenResponse {
token?: string;
access_token?: string;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly settingsService = inject(SettingsService);
private readonly _token = signal<string | null>(localStorage.getItem(TOKEN_KEY));
/** Aktuell gespeicherter Bearer-Token (reaktiv) */
readonly token = this._token.asReadonly();
/** Gibt den aktuellen Token zurück */
getToken(): string | null {
return this._token();
}
/** Löscht den Token aus Speicher und State */
clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
this._token.set(null);
}
/**
* Sendet Anmeldedaten an den Auth-Endpunkt und speichert den erhaltenen Token.
* Unterstützt sowohl `{ token }` als auch `{ access_token }` als Antwortformat.
*/
async login(username: string, password: string): Promise<string> {
const { apiBaseUrl, authEndpoint } = this.settingsService.settings();
const url = `${apiBaseUrl}${authEndpoint}`;
const response = await firstValueFrom(
this.http.post<TokenResponse>(url, { username, password })
);
const token = response.token ?? response.access_token;
if (!token) {
throw new Error('Kein Token in der Server-Antwort gefunden (erwartet: token oder access_token)');
}
localStorage.setItem(TOKEN_KEY, token);
this._token.set(token);
return token;
}
/**
* Wird beim App-Start aufgerufen.
* Wenn Anmeldedaten gespeichert sind und kein Mock-Modus aktiv ist,
* wird automatisch ein frischer Token abgerufen.
*/
async initialize(): Promise<void> {
const { username, password, useMocks } = this.settingsService.settings();
if (useMocks || !username || !password) {
return;
}
try {
await this.login(username, password);
} catch (err) {
console.warn('[AuthService] Auto-Login beim Start fehlgeschlagen:', err);
}
}
}

View File

@@ -0,0 +1,50 @@
import { inject, Injectable, OnDestroy } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import {
Observable,
timer,
switchMap,
distinctUntilChanged,
shareReplay,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { PvLiveData, PvHistoryData } from '../models/pv-data.model';
import { ApiResponse } from '../models/api-response.model';
import { SettingsService } from './settings.service';
@Injectable({ providedIn: 'root' })
export class LiveDataService implements OnDestroy {
private readonly http = inject(HttpClient);
private readonly settingsService = inject(SettingsService);
/** Liefert Livedaten per Polling alle `pollingIntervalMs` Millisekunden. */
getLiveData(plantId: string): Observable<PvLiveData> {
const intervalMs = this.settingsService.settings().pollingIntervalMs;
return timer(0, intervalMs).pipe(
switchMap(() => {
const { apiBaseUrl, endpointLiveData } = this.settingsService.settings();
const url = apiBaseUrl + this.settingsService.resolveEndpoint(endpointLiveData, plantId);
return this.http
.get<ApiResponse<PvLiveData>>(url)
.pipe(map((res) => res.data));
}),
distinctUntilChanged(
(a, b) => a.timestamp === b.timestamp
),
shareReplay({ bufferSize: 1, refCount: true })
);
}
getHistory(
plantId: string,
resolution: 'hour' | 'day' | 'month' | 'year' = 'day'
): Observable<PvHistoryData> {
const { apiBaseUrl, endpointHistory } = this.settingsService.settings();
const url = apiBaseUrl + this.settingsService.resolveEndpoint(endpointHistory, plantId);
return this.http
.get<ApiResponse<PvHistoryData>>(url, { params: { resolution } })
.pipe(map((res) => res.data));
}
ngOnDestroy(): void {}
}

View File

@@ -0,0 +1,27 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { PvPlant } from '../models/pv-plant.model';
import { ApiResponse } from '../models/api-response.model';
import { SettingsService } from './settings.service';
@Injectable({ providedIn: 'root' })
export class PvPlantService {
private readonly http = inject(HttpClient);
private readonly settingsService = inject(SettingsService);
getAll(): Observable<PvPlant[]> {
const { apiBaseUrl, endpointPlants } = this.settingsService.settings();
return this.http
.get<ApiResponse<PvPlant[]>>(apiBaseUrl + endpointPlants)
.pipe(map((res) => res.data));
}
getById(id: string): Observable<PvPlant> {
const { apiBaseUrl, endpointPlants } = this.settingsService.settings();
return this.http
.get<ApiResponse<PvPlant>>(`${apiBaseUrl}${endpointPlants}/${encodeURIComponent(id)}`)
.pipe(map((res) => res.data));
}
}

View File

@@ -0,0 +1,78 @@
import { Injectable, signal } from '@angular/core';
import { environment } from '../../../environments/environment';
import { AppSettings } from '../models/settings.model';
const STORAGE_KEY = 'pv_app_settings';
@Injectable({ providedIn: 'root' })
export class SettingsService {
private readonly _settings = signal<AppSettings>(this.loadFromStorage());
private _pinVerified = false;
/** Aktuell aktive Einstellungen (reaktiv) */
readonly settings = this._settings.asReadonly();
private defaultSettings(): AppSettings {
return {
apiBaseUrl: environment.apiBaseUrl,
useMocks: environment.useMocks,
pollingIntervalMs: environment.pollingIntervalMs,
authEndpoint: '/auth/token',
username: '',
password: '',
endpointPlants: '/plants',
endpointLiveData: '/plants/{id}/live',
endpointHistory: '/plants/{id}/history',
};
}
private loadFromStorage(): AppSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
return { ...this.defaultSettings(), ...(JSON.parse(raw) as Partial<AppSettings>) };
}
} catch {
// localStorage nicht verfügbar oder ungültiges JSON Standardwerte verwenden
}
return this.defaultSettings();
}
/** Speichert Einstellungen in localStorage und aktualisiert das reaktive Signal */
save(settings: AppSettings): void {
this._settings.set({ ...settings });
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
/** Löst den {id}-Platzhalter in einem Endpunkt-Template auf */
resolveEndpoint(template: string, id: string): string {
return template.replace('{id}', encodeURIComponent(id));
}
/** Prüft ob der eingegebene PIN dem heutigen Datum entspricht (DDMM) */
isValidPin(pin: string): boolean {
const now = new Date();
const day = String(now.getDate()).padStart(2, '0');
const month = String(now.getMonth() + 1).padStart(2, '0');
return pin === `${day}${month}`;
}
/** Verifiziert den PIN und merkt sich das Ergebnis für den Route-Guard */
verifyPin(pin: string): boolean {
if (this.isValidPin(pin)) {
this._pinVerified = true;
return true;
}
return false;
}
/** Gibt zurück ob der PIN in dieser Session bereits erfolgreich eingegeben wurde */
get isPinVerified(): boolean {
return this._pinVerified;
}
/** Setzt die PIN-Verifizierung zurück (z.B. nach Logout) */
resetPinVerification(): void {
this._pinVerified = false;
}
}

View File

@@ -0,0 +1,48 @@
<main class="dashboard">
<h1>Dashboard</h1>
@if (loading()) {
<app-loading-spinner />
} @else if (error()) {
<app-error-message [message]="error()!" />
} @else {
<!-- KPIs -->
<section class="kpi-grid" aria-label="Kennzahlen">
<article class="kpi-card">
<span class="kpi-label">Anlagen gesamt</span>
<span class="kpi-value">{{ plants().length }}</span>
</article>
<article class="kpi-card kpi-card--green">
<span class="kpi-label">Online</span>
<span class="kpi-value">{{ onlineCount }}</span>
</article>
<article class="kpi-card">
<span class="kpi-label">Gesamtleistung</span>
<span class="kpi-value">{{ totalCapacityKwp | number:'1.1-1' }} kWp</span>
</article>
</section>
<!-- Anlagenliste -->
<section class="plant-overview" aria-label="Anlagenübersicht">
<h2>Anlagen</h2>
<ul class="plant-list" role="list">
@for (plant of plants(); track plant.id) {
<li class="plant-card">
<a [routerLink]="['/plants', plant.id]" class="plant-link">
<div class="plant-info">
<strong>{{ plant.name }}</strong>
<span class="plant-location">{{ plant.location }}</span>
</div>
<div class="plant-meta">
<span>{{ plant.installedCapacityKwp }} kWp</span>
<app-status-badge [status]="plant.status" />
</div>
</a>
</li>
} @empty {
<li class="empty-state">Keine Anlagen vorhanden.</li>
}
</ul>
</section>
}
</main>

View File

@@ -0,0 +1,73 @@
.dashboard { padding: 1.5rem; }
h1 { margin-bottom: 1.5rem; color: #1a237e; }
h2 { margin: 1.5rem 0 0.75rem; font-size: 1.1rem; color: #37474f; }
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.kpi-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1.25rem;
background: #fff;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0,0,0,0.12);
&--green .kpi-value { color: #2e7d32; }
.kpi-label { font-size: 0.78rem; color: #757575; text-transform: uppercase; letter-spacing: 0.05em; }
.kpi-value { font-size: 2rem; font-weight: 700; color: #212121; }
}
.plant-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.plant-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
transition: box-shadow 0.2s;
&:hover { box-shadow: 0 3px 10px rgba(0,0,0,0.15); }
}
.plant-link {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
color: inherit;
text-decoration: none;
}
.plant-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
.plant-location { font-size: 0.85rem; color: #757575; }
}
.plant-meta {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.9rem;
color: #424242;
}
.empty-state {
padding: 2rem;
text-align: center;
color: #9e9e9e;
}

View File

@@ -0,0 +1,49 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { PvPlantService } from '../../core/services/pv-plant.service';
import { PvPlant } from '../../core/models/pv-plant.model';
import { LoadingSpinnerComponent, ErrorMessageComponent, StatusBadgeComponent } from '../../shared';
import { RouterLink } from '@angular/router';
import { DecimalPipe } from '@angular/common';
@Component({
selector: 'app-dashboard',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LoadingSpinnerComponent, ErrorMessageComponent, StatusBadgeComponent, RouterLink, DecimalPipe],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.scss',
})
export class DashboardComponent implements OnInit {
private readonly plantService = inject(PvPlantService);
readonly plants = signal<PvPlant[]>([]);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
ngOnInit(): void {
this.plantService.getAll().subscribe({
next: (data) => {
this.plants.set(data);
this.loading.set(false);
},
error: () => {
this.error.set('Anlagen konnten nicht geladen werden.');
this.loading.set(false);
},
});
}
get onlineCount(): number {
return this.plants().filter((p) => p.status === 'online').length;
}
get totalCapacityKwp(): number {
return this.plants().reduce((sum, p) => sum + p.installedCapacityKwp, 0);
}
}

View File

@@ -0,0 +1,78 @@
<section class="plant-detail">
@if (loading()) {
<app-loading-spinner />
} @else if (error()) {
<app-error-message [message]="error()!" />
} @else if (plant(); as p) {
<!-- Stammdaten -->
<section class="info-card" aria-label="Anlagendetails">
<h2>Anlagendetails</h2>
<dl class="detail-grid">
<dt>Installierte Leistung</dt><dd>{{ p.installedCapacityKwp }} kWp</dd>
<dt>Wechselrichter</dt><dd>{{ p.inverterCount }}</dd>
<dt>Module</dt><dd>{{ p.moduleCount }}</dd>
<dt>In Betrieb seit</dt><dd>{{ p.installedAt | date:'dd.MM.yyyy' }}</dd>
</dl>
</section>
<!-- Livedaten -->
@if (liveData(); as live) {
<section class="info-card live-section" aria-label="Livedaten">
<h2>Livedaten <span class="live-badge" aria-label="Live">● LIVE</span></h2>
<div class="kpi-grid">
<article class="kpi-card kpi-card--primary">
<span class="kpi-label">Aktuelle Leistung</span>
<span class="kpi-value">{{ live.currentPowerKw | watt }}</span>
</article>
<article class="kpi-card">
<span class="kpi-label">Heute erzeugt</span>
<span class="kpi-value">{{ live.todayEnergyKwh | number:'1.1-2' }} kWh</span>
</article>
<article class="kpi-card">
<span class="kpi-label">Gesamt erzeugt</span>
<span class="kpi-value">{{ live.totalEnergyKwh | number:'1.0-0' }} kWh</span>
</article>
<article class="kpi-card kpi-card--green">
<span class="kpi-label">CO₂ eingespart</span>
<span class="kpi-value">{{ live.co2SavedKg | number:'1.0-0' }} kg</span>
</article>
@if (performanceRatio(); as pr) {
<article class="kpi-card">
<span class="kpi-label">Performance Ratio</span>
<span class="kpi-value">{{ pr | number:'1.0-1' }} %</span>
</article>
}
<article class="kpi-card">
<span class="kpi-label">Einspeisung</span>
<span class="kpi-value">{{ live.gridFeedInKw | watt }}</span>
</article>
<article class="kpi-card">
<span class="kpi-label">Eigenverbrauch</span>
<span class="kpi-value">{{ live.selfConsumptionKw | watt }}</span>
</article>
<article class="kpi-card">
<span class="kpi-label">Einstrahlung</span>
<span class="kpi-value">{{ live.irradianceWm2 | number:'1.0-0' }} W/m²</span>
</article>
</div>
<!-- Wechselrichter -->
@if (live.inverters.length) {
<h3>Wechselrichter</h3>
<ul class="inverter-list" role="list">
@for (inv of live.inverters; track inv.id) {
<li class="inverter-card">
<strong>{{ inv.name }}</strong>
<span>{{ inv.powerKw | watt }}</span>
<app-status-badge [status]="inv.status" />
</li>
}
</ul>
}
</section>
} @else {
<app-loading-spinner />
}
}
</section>

View File

@@ -0,0 +1,65 @@
.plant-detail { padding: 1.5rem; }
.info-card {
background: #fff;
border-radius: 10px;
padding: 1.5rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
margin-bottom: 1.5rem;
h2 { margin: 0 0 1rem; font-size: 1.05rem; color: #37474f; display: flex; align-items: center; gap: 0.75rem; }
h3 { margin: 1.25rem 0 0.75rem; font-size: 0.95rem; color: #37474f; }
}
.live-badge {
font-size: 0.7rem;
background: #e8f5e9;
color: #2e7d32;
padding: 0.15rem 0.5rem;
border-radius: 20px;
animation: pulse 2s infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.detail-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1.5rem;
margin: 0;
dt { font-weight: 600; color: #616161; font-size: 0.85rem; }
dd { margin: 0; color: #212121; }
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.kpi-card {
display: flex; flex-direction: column; gap: 0.2rem;
padding: 1rem 1.25rem;
background: #f5f5f5;
border-radius: 8px;
&--primary .kpi-value { color: #1565c0; }
&--green .kpi-value { color: #2e7d32; }
.kpi-label { font-size: 0.75rem; color: #757575; text-transform: uppercase; letter-spacing: 0.04em; }
.kpi-value { font-size: 1.6rem; font-weight: 700; color: #212121; }
}
.inverter-list {
list-style: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 0.5rem;
}
.inverter-card {
display: flex; justify-content: space-between; align-items: center;
padding: 0.65rem 1rem;
background: #f5f5f5;
border-radius: 6px;
font-size: 0.9rem;
}

View File

@@ -0,0 +1,73 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
OnDestroy,
signal,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { PvPlantService } from '../../core/services/pv-plant.service';
import { LiveDataService } from '../../core/services/live-data.service';
import { PvPlant } from '../../core/models/pv-plant.model';
import { PvLiveData } from '../../core/models/pv-data.model';
import {
LoadingSpinnerComponent,
ErrorMessageComponent,
StatusBadgeComponent,
WattPipe,
} from '../../shared';
import { DatePipe, DecimalPipe } from '@angular/common';
@Component({
selector: 'app-plant-detail',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LoadingSpinnerComponent, ErrorMessageComponent, StatusBadgeComponent, WattPipe, DecimalPipe, DatePipe],
templateUrl: './plant-detail.component.html',
styleUrl: './plant-detail.component.scss',
})
export class PlantDetailComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly plantService = inject(PvPlantService);
private readonly liveDataService = inject(LiveDataService);
readonly plant = signal<PvPlant | null>(null);
readonly liveData = signal<PvLiveData | null>(null);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly performanceRatio = computed(() => {
const live = this.liveData();
const p = this.plant();
if (!live || !p || p.installedCapacityKwp === 0) return null;
return (live.currentPowerKw / p.installedCapacityKwp) * 100;
});
private subs = new Subscription();
ngOnInit(): void {
const id =
this.route.parent?.snapshot.paramMap.get('id') ??
this.route.snapshot.paramMap.get('id') ??
'';
this.subs.add(
this.plantService.getById(id).subscribe({
next: (data) => { this.plant.set(data); this.loading.set(false); },
error: () => { this.error.set('Anlage konnte nicht geladen werden.'); this.loading.set(false); },
})
);
this.subs.add(
this.liveDataService.getLiveData(id).subscribe({
next: (data) => this.liveData.set(data),
})
);
}
ngOnDestroy(): void {
this.subs.unsubscribe();
}
}

View File

@@ -0,0 +1,102 @@
<section class="plant-history">
<!-- Toolbar: Auflösung wählen -->
<div class="toolbar">
<h2>Ertragsverlauf</h2>
<div class="resolution-switcher" role="group" aria-label="Zeitauflösung wählen">
@for (r of resolutions; track r.value) {
<button
type="button"
[class.active]="resolution() === r.value"
(click)="setResolution(r.value)"
[attr.aria-pressed]="resolution() === r.value"
>
{{ r.label }}
</button>
}
</div>
</div>
@if (loading()) {
<app-loading-spinner />
} @else if (error()) {
<app-error-message [message]="error()!" />
} @else if (historyData(); as data) {
<!-- KPI-Zusammenfassung -->
<div class="kpi-row">
<article class="kpi-card">
<span class="kpi-label">Gesamtertrag (Zeitraum)</span>
<span class="kpi-value">{{ totalEnergy() | number:'1.1-2' }} kWh</span>
</article>
@if (peakEntry(); as peak) {
<article class="kpi-card kpi-card--yellow">
<span class="kpi-label">Spitzenleistung</span>
<span class="kpi-value">{{ peak.peakPowerKw | number:'1.1-2' }} kW</span>
<span class="kpi-sub">{{ peak.timestamp | date:dateFormat(resolution()) }}</span>
</article>
}
<article class="kpi-card">
<span class="kpi-label">Datenpunkte</span>
<span class="kpi-value">{{ data.entries.length }}</span>
</article>
</div>
<!-- Balkendiagramm (CSS-basiert) -->
@if (chartBarData().length) {
<div class="chart-section">
<h3>Ertrag je Zeiteinheit (kWh)</h3>
<div
class="bar-chart"
role="img"
[attr.aria-label]="'Balkendiagramm Ertrag ' + resolution()"
>
@for (bar of chartBarData(); track bar.timestamp) {
<div class="bar-wrap">
<span class="bar-value">{{ bar.energyKwh | number:'1.0-1' }}</span>
<div
class="bar"
[style.height.%]="bar.barPercent"
[attr.aria-label]="(bar.timestamp | date:dateFormat(resolution())) + ': ' + bar.energyKwh + ' kWh'"
></div>
<span class="bar-label">{{ bar.timestamp | date:dateFormat(resolution()) }}</span>
</div>
}
</div>
</div>
}
<!-- Datentabelle -->
<div class="table-section">
<h3>Detailtabelle</h3>
<div class="table-wrapper">
<table aria-label="Verlaufsdaten Tabelle">
<thead>
<tr>
<th scope="col">Zeitpunkt</th>
<th scope="col">Ertrag (kWh)</th>
<th scope="col">Spitzenleistung (kW)</th>
</tr>
</thead>
<tbody>
@for (entry of data.entries; track entry.timestamp) {
<tr>
<td>{{ entry.timestamp | date:dateFormat(resolution()) }}</td>
<td>{{ entry.energyKwh | number:'1.2-2' }}</td>
<td>{{ entry.peakPowerKw | number:'1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="3" class="empty-state">Keine Daten für diesen Zeitraum.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
} @else {
<p class="empty-state">Keine Verlaufsdaten verfügbar.</p>
}
</section>

View File

@@ -0,0 +1,179 @@
.plant-history {
padding: 1.5rem;
}
/* ── Toolbar ─────────────────────────────── */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
h2 {
margin: 0;
color: #37474f;
font-size: 1.1rem;
}
}
.resolution-switcher {
display: flex;
gap: 0.25rem;
background: #eeeeee;
border-radius: 8px;
padding: 0.25rem;
button {
padding: 0.4rem 0.9rem;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
color: #616161;
transition: background 0.15s, color 0.15s;
&:hover { background: #e0e0e0; }
&.active {
background: #1a237e;
color: #fff;
}
}
}
/* ── KPIs ─────────────────────────────────── */
.kpi-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 2rem;
}
.kpi-card {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 1rem 1.25rem;
background: #fff;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
min-width: 160px;
&--yellow .kpi-value { color: #e65100; }
.kpi-label { font-size: 0.75rem; color: #757575; text-transform: uppercase; letter-spacing: 0.04em; }
.kpi-value { font-size: 1.75rem; font-weight: 700; color: #212121; }
.kpi-sub { font-size: 0.75rem; color: #9e9e9e; }
}
/* ── Balkendiagramm ─────────────────────── */
.chart-section {
margin-bottom: 2rem;
background: #fff;
border-radius: 10px;
padding: 1.25rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
h3 {
margin: 0 0 1rem;
font-size: 0.9rem;
color: #37474f;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.bar-chart {
display: flex;
align-items: flex-end;
gap: 4px;
height: 180px;
overflow-x: auto;
padding-bottom: 2rem;
position: relative;
}
.bar-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
min-width: 36px;
flex: 1;
height: 100%;
position: relative;
.bar-value {
font-size: 0.65rem;
color: #9e9e9e;
margin-bottom: 3px;
white-space: nowrap;
}
.bar {
width: 100%;
min-height: 2px;
background: linear-gradient(180deg, #3949ab, #1a237e);
border-radius: 3px 3px 0 0;
transition: height 0.4s ease;
}
.bar-label {
position: absolute;
bottom: -1.5rem;
font-size: 0.6rem;
color: #9e9e9e;
white-space: nowrap;
transform: rotate(-30deg);
transform-origin: top left;
}
}
/* ── Tabelle ─────────────────────────────── */
.table-section {
background: #fff;
border-radius: 10px;
padding: 1.25rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
h3 {
margin: 0 0 1rem;
font-size: 0.9rem;
color: #37474f;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.table-wrapper { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
th, td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #f5f5f5;
font-size: 0.9rem;
}
th {
background: #f5f5f5;
font-size: 0.78rem;
text-transform: uppercase;
color: #3949ab;
letter-spacing: 0.04em;
}
tbody tr:hover { background: #fafafa; }
}
.empty-state {
text-align: center;
color: #9e9e9e;
padding: 2rem;
}

View File

@@ -0,0 +1,100 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { LiveDataService } from '../../core/services/live-data.service';
import { PvHistoryData, PvHistoryEntry } from '../../core/models/pv-data.model';
import { LoadingSpinnerComponent, ErrorMessageComponent } from '../../shared';
import { DecimalPipe, DatePipe } from '@angular/common';
type Resolution = 'hour' | 'day' | 'month' | 'year';
@Component({
selector: 'app-plant-history',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LoadingSpinnerComponent, ErrorMessageComponent, DecimalPipe, DatePipe],
templateUrl: './plant-history.component.html',
styleUrl: './plant-history.component.scss',
})
export class PlantHistoryComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly liveDataService = inject(LiveDataService);
readonly resolution = signal<Resolution>('day');
readonly historyData = signal<PvHistoryData | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly resolutions: { value: Resolution; label: string }[] = [
{ value: 'hour', label: 'Stündlich' },
{ value: 'day', label: 'Täglich' },
{ value: 'month', label: 'Monatlich' },
{ value: 'year', label: 'Jährlich' },
];
readonly totalEnergy = computed(() =>
this.historyData()?.entries.reduce((sum, e) => sum + e.energyKwh, 0) ?? 0
);
readonly peakEntry = computed(() => {
const entries = this.historyData()?.entries ?? [];
if (!entries.length) return null;
return entries.reduce((max, e) => (e.peakPowerKw > max.peakPowerKw ? e : max));
});
readonly chartBarData = computed(() => {
const entries = this.historyData()?.entries ?? [];
if (!entries.length) return [];
const max = Math.max(...entries.map((e) => e.energyKwh));
return entries.map((e) => ({
...e,
barPercent: max > 0 ? (e.energyKwh / max) * 100 : 0,
}));
});
private plantId = '';
ngOnInit(): void {
// plantId kommt vom Parent-Route-Snapshot (plant-shell)
this.plantId =
this.route.parent?.snapshot.paramMap.get('id') ??
this.route.snapshot.paramMap.get('id') ??
'';
this.loadHistory();
}
loadHistory(): void {
if (!this.plantId) return;
this.loading.set(true);
this.error.set(null);
this.liveDataService.getHistory(this.plantId, this.resolution()).subscribe({
next: (data) => {
this.historyData.set(data);
this.loading.set(false);
},
error: () => {
this.error.set('Verlaufsdaten konnten nicht geladen werden.');
this.loading.set(false);
},
});
}
setResolution(res: Resolution): void {
this.resolution.set(res);
this.loadHistory();
}
dateFormat(resolution: Resolution): string {
switch (resolution) {
case 'hour': return 'dd.MM.yy HH:mm';
case 'day': return 'dd.MM.yyyy';
case 'month': return 'MMM yyyy';
case 'year': return 'yyyy';
}
}
}

View File

@@ -0,0 +1,42 @@
<main class="plant-list-page">
<h1>Alle Anlagen</h1>
@if (loading()) {
<app-loading-spinner />
} @else if (error()) {
<app-error-message [message]="error()!" />
} @else {
<table class="plant-table" aria-label="Anlagenübersicht">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Standort</th>
<th scope="col">Leistung (kWp)</th>
<th scope="col">Wechselrichter</th>
<th scope="col">Module</th>
<th scope="col">Status</th>
<th scope="col">Aktion</th>
</tr>
</thead>
<tbody>
@for (plant of plants(); track plant.id) {
<tr>
<td>{{ plant.name }}</td>
<td>{{ plant.location }}</td>
<td>{{ plant.installedCapacityKwp }}</td>
<td>{{ plant.inverterCount }}</td>
<td>{{ plant.moduleCount }}</td>
<td><app-status-badge [status]="plant.status" /></td>
<td>
<a [routerLink]="['/plants', plant.id]" class="detail-link">
Details →
</a>
</td>
</tr>
} @empty {
<tr><td colspan="7" class="empty-state">Keine Anlagen vorhanden.</td></tr>
}
</tbody>
</table>
}
</main>

View File

@@ -0,0 +1,36 @@
.plant-list-page { padding: 1.5rem; }
h1 { color: #1a237e; margin-bottom: 1.5rem; }
.plant-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
th, td {
padding: 0.85rem 1rem;
text-align: left;
border-bottom: 1px solid #eeeeee;
}
th {
background: #e8eaf6;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #3949ab;
}
tbody tr:hover { background: #f5f5f5; }
}
.detail-link {
color: #1976d2;
text-decoration: none;
font-weight: 500;
&:hover { text-decoration: underline; }
}
.empty-state { text-align: center; color: #9e9e9e; padding: 2rem; }

View File

@@ -0,0 +1,37 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
signal,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PvPlantService } from '../../core/services/pv-plant.service';
import { PvPlant } from '../../core/models/pv-plant.model';
import {
LoadingSpinnerComponent,
ErrorMessageComponent,
StatusBadgeComponent,
} from '../../shared';
@Component({
selector: 'app-plant-list',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, LoadingSpinnerComponent, ErrorMessageComponent, StatusBadgeComponent],
templateUrl: './plant-list.component.html',
styleUrl: './plant-list.component.scss',
})
export class PlantListComponent implements OnInit {
private readonly plantService = inject(PvPlantService);
readonly plants = signal<PvPlant[]>([]);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
ngOnInit(): void {
this.plantService.getAll().subscribe({
next: (data) => { this.plants.set(data); this.loading.set(false); },
error: () => { this.error.set('Anlagenliste konnte nicht geladen werden.'); this.loading.set(false); },
});
}
}

View File

@@ -0,0 +1,36 @@
@if (plant(); as p) {
<div class="plant-shell">
<!-- Anlagen-Header -->
<header class="plant-header">
<div class="plant-header__info">
<h1>{{ p.name }}</h1>
<span class="plant-header__location">📍 {{ p.location }}</span>
</div>
<app-status-badge [status]="p.status" />
</header>
<!-- Tab-Navigation -->
<nav class="tab-nav" aria-label="Anlagen-Navigation">
<a
[routerLink]="['overview']"
routerLinkActive="tab-nav__link--active"
class="tab-nav__link"
[routerLinkActiveOptions]="{ exact: true }"
>
<span aria-hidden="true"></span> Livedaten
</a>
<a
[routerLink]="['history']"
routerLinkActive="tab-nav__link--active"
class="tab-nav__link"
>
<span aria-hidden="true">📈</span> Verlauf
</a>
</nav>
<!-- Child-Route Outlet -->
<div class="tab-content">
<router-outlet />
</div>
</div>
}

View File

@@ -0,0 +1,64 @@
.plant-shell {
display: flex;
flex-direction: column;
height: 100%;
}
.plant-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1.5rem 1.5rem 0;
&__info {
display: flex;
flex-direction: column;
gap: 0.25rem;
h1 {
margin: 0;
color: #1a237e;
}
}
&__location {
color: #757575;
font-size: 0.9rem;
}
}
.tab-nav {
display: flex;
gap: 0;
padding: 1rem 1.5rem 0;
border-bottom: 2px solid #e0e0e0;
margin-top: 1rem;
&__link {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 1.25rem;
color: #616161;
text-decoration: none;
font-weight: 500;
font-size: 0.95rem;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
transition: color 0.2s, border-color 0.2s;
&:hover {
color: #1a237e;
}
&--active {
color: #1a237e;
border-color: #1a237e;
}
}
}
.tab-content {
flex: 1;
overflow-y: auto;
}

View File

@@ -0,0 +1,34 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
signal,
} from '@angular/core';
import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { PvPlantService } from '../../core/services/pv-plant.service';
import { PvPlant } from '../../core/models/pv-plant.model';
import { StatusBadgeComponent } from '../../shared';
@Component({
selector: 'app-plant-shell',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, RouterLink, RouterLinkActive, StatusBadgeComponent],
templateUrl: './plant-shell.component.html',
styleUrl: './plant-shell.component.scss',
})
export class PlantShellComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly plantService = inject(PvPlantService);
readonly plant = signal<PvPlant | null>(null);
readonly plantId = signal<string>('');
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id')!;
this.plantId.set(id);
this.plantService.getById(id).subscribe({
next: (data) => this.plant.set(data),
});
}
}

View File

@@ -0,0 +1,229 @@
<div class="settings-page">
<header class="settings-header">
<button type="button" class="btn-back" (click)="goBack()" aria-label="Zurück zum Dashboard">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg>
Zurück
</button>
<h1>
<svg class="settings-header__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7zm7.43-2.03c.04-.32.07-.65.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11.5c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.58 1.69-.98l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66z"/>
</svg>
Einstellungen
</h1>
</header>
<form [formGroup]="form" (ngSubmit)="save()" class="settings-form" novalidate>
<!-- ── API-Konfiguration ─────────────────────────────── -->
<section class="settings-section">
<h2 class="section-title">API-Konfiguration</h2>
<div class="form-group">
<label for="apiBaseUrl">API Basis-URL</label>
<input
id="apiBaseUrl"
type="url"
formControlName="apiBaseUrl"
placeholder="https://api.example.com/api"
autocomplete="off"
/>
@if (form.controls.apiBaseUrl.invalid && form.controls.apiBaseUrl.touched) {
<span class="field-error">Pflichtfeld bitte eine gültige URL eingeben</span>
}
</div>
<div class="form-group form-group--checkbox">
<input id="useMocks" type="checkbox" formControlName="useMocks" />
<label for="useMocks">Mock-Modus aktiv (kein echter API-Aufruf)</label>
</div>
<div class="form-group">
<label for="pollingIntervalMs">Abfrageintervall Live-Daten (ms)</label>
<input
id="pollingIntervalMs"
type="number"
formControlName="pollingIntervalMs"
min="1000"
step="500"
/>
@if (form.controls.pollingIntervalMs.invalid && form.controls.pollingIntervalMs.touched) {
<span class="field-error">Minimum 1000 ms</span>
}
</div>
</section>
<!-- ── Endpunkte ────────────────────────────────────── -->
<section class="settings-section">
<h2 class="section-title">Endpunkte</h2>
<p class="section-hint">Verwende <code>&#123;id&#125;</code> als Platzhalter f&#252;r die Anlagen-ID</p>
<div class="form-group">
<label for="endpointPlants">Anlagenliste</label>
<input
id="endpointPlants"
type="text"
formControlName="endpointPlants"
placeholder="/plants"
autocomplete="off"
/>
@if (form.controls.endpointPlants.invalid && form.controls.endpointPlants.touched) {
<span class="field-error">Pflichtfeld</span>
}
</div>
<div class="form-group">
<label for="endpointLiveData">Live-Daten</label>
<input
id="endpointLiveData"
type="text"
formControlName="endpointLiveData"
placeholder="/plants/{id}/live"
autocomplete="off"
/>
@if (form.controls.endpointLiveData.invalid && form.controls.endpointLiveData.touched) {
<span class="field-error">Pflichtfeld</span>
}
</div>
<div class="form-group">
<label for="endpointHistory">Historien-Daten</label>
<input
id="endpointHistory"
type="text"
formControlName="endpointHistory"
placeholder="/plants/{id}/history"
autocomplete="off"
/>
@if (form.controls.endpointHistory.invalid && form.controls.endpointHistory.touched) {
<span class="field-error">Pflichtfeld</span>
}
</div>
</section>
<!-- ── Authentifizierung ─────────────────────────────── -->
<section class="settings-section">
<h2 class="section-title">Authentifizierung</h2>
<div class="form-group">
<label for="authEndpoint">Auth-Endpunkt (relativer Pfad)</label>
<input
id="authEndpoint"
type="text"
formControlName="authEndpoint"
placeholder="/auth/token"
autocomplete="off"
/>
@if (form.controls.authEndpoint.invalid && form.controls.authEndpoint.touched) {
<span class="field-error">Pflichtfeld</span>
}
</div>
<div class="form-group">
<label for="username">Benutzername</label>
<input
id="username"
type="text"
formControlName="username"
autocomplete="username"
/>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<div class="input-with-action">
<input
id="password"
[type]="showPassword() ? 'text' : 'password'"
formControlName="password"
autocomplete="current-password"
/>
<button
type="button"
class="btn-icon"
(click)="showPassword.set(!showPassword())"
[attr.aria-label]="showPassword() ? 'Passwort verbergen' : 'Passwort anzeigen'"
title="{{ showPassword() ? 'Passwort verbergen' : 'Passwort anzeigen' }}"
>
@if (showPassword()) {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46A11.804 11.804 0 0 0 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/>
</svg>
} @else {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"/>
</svg>
}
</button>
</div>
</div>
<!-- Token-Status -->
<div class="token-card">
<div class="token-card__status">
@if (token()) {
<div class="token-indicator token-indicator--active">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
<span>Token aktiv</span>
</div>
<code class="token-value">{{ maskedToken }}</code>
<button type="button" class="btn-ghost btn-sm" (click)="clearToken()">Löschen</button>
} @else {
<div class="token-indicator token-indicator--missing">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>
<span>Kein Token gesetzt</span>
</div>
}
</div>
@if (tokenStatus() === 'loading') {
<p class="token-card__msg token-card__msg--info">Token wird abgerufen…</p>
}
@if (tokenStatus() === 'success') {
<p class="token-card__msg token-card__msg--success">✓ Token erfolgreich abgerufen</p>
}
@if (tokenStatus() === 'error') {
<p class="token-card__msg token-card__msg--error">✗ {{ tokenError() }}</p>
}
</div>
<button
type="button"
class="btn-secondary"
(click)="fetchToken()"
[disabled]="!form.controls.username.value || !form.controls.password.value || tokenStatus() === 'loading'"
>
@if (tokenStatus() === 'loading') {
<span class="btn-spinner"></span>
Token wird abgerufen…
} @else {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4l4-4-4-4v4h-4.35zM7 16c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z"/>
</svg>
Token abrufen
}
</button>
</section>
<!-- ── Aktionen ──────────────────────────────────────── -->
<div class="settings-actions">
@if (saveSuccess()) {
<span class="save-success">✓ Einstellungen gespeichert</span>
}
<button type="button" class="btn-secondary" (click)="goBack()">Abbrechen</button>
<button type="submit" class="btn-primary" [disabled]="form.invalid">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/>
</svg>
Einstellungen speichern
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,312 @@
:host {
display: block;
}
.settings-page {
max-width: 700px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
/* ── Header ─────────────────────────────────────────────── */
.settings-header {
display: flex;
align-items: center;
gap: 1.25rem;
margin-bottom: 2rem;
h1 {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1a1a1a;
}
&__icon {
color: #cc0000;
flex-shrink: 0;
}
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: none;
border: 1px solid #ddd;
border-radius: 8px;
padding: 0.45rem 0.9rem;
font-size: 0.875rem;
color: #555;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
white-space: nowrap;
&:hover {
background: #f5f5f5;
border-color: #ccc;
}
}
/* ── Form layout ─────────────────────────────────────────── */
.settings-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* ── Section card ────────────────────────────────────────── */
.settings-section {
background: #fff;
border: 1px solid #e4e4e4;
border-radius: 12px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.section-title {
margin: 0 0 0.25rem;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #888;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 0.75rem;
}
.section-hint {
margin: -0.25rem 0 0.25rem;
font-size: 0.8rem;
color: #999;
code {
background: #f0f0f0;
border-radius: 3px;
padding: 0.1rem 0.3rem;
font-size: 0.78rem;
color: #cc0000;
}
}
/* ── Form group ──────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
label {
font-size: 0.85rem;
font-weight: 500;
color: #444;
}
input[type='text'],
input[type='url'],
input[type='number'],
input[type='password'] {
border: 1px solid #d0d0d0;
border-radius: 8px;
padding: 0.55rem 0.8rem;
font-size: 0.95rem;
color: #1a1a1a;
transition: border-color 0.2s, box-shadow 0.2s;
&:focus {
outline: none;
border-color: #cc0000;
box-shadow: 0 0 0 3px rgba(204, 0, 0, 0.1);
}
}
}
.form-group--checkbox {
flex-direction: row;
align-items: center;
gap: 0.65rem;
padding: 0.25rem 0;
input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: #cc0000;
cursor: pointer;
flex-shrink: 0;
}
label {
margin: 0;
cursor: pointer;
}
}
.field-error {
color: #c62828;
font-size: 0.78rem;
}
/* ── Input with icon button ──────────────────────────────── */
.input-with-action {
display: flex;
gap: 0.5rem;
input {
flex: 1;
min-width: 0;
}
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid #d0d0d0;
border-radius: 8px;
padding: 0 0.65rem;
color: #666;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #f5f5f5;
}
}
/* ── Token card ──────────────────────────────────────────── */
.token-card {
background: #f9f9f9;
border: 1px solid #ebebeb;
border-radius: 10px;
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
&__status {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.6rem;
}
&__msg {
margin: 0;
font-size: 0.83rem;
&--info { color: #1565c0; }
&--success { color: #2e7d32; }
&--error { color: #c62828; }
}
}
.token-indicator {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
font-weight: 500;
&--active { color: #2e7d32; }
&--missing { color: #e65100; }
}
.token-value {
font-family: monospace;
font-size: 0.8rem;
background: #e8e8e8;
border-radius: 4px;
padding: 0.15rem 0.5rem;
color: #333;
word-break: break-all;
}
/* ── Buttons ─────────────────────────────────────────────── */
.btn-primary,
.btn-secondary,
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 1.2rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
white-space: nowrap;
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.btn-primary {
background: #cc0000;
color: #fff;
border: none;
&:hover:not(:disabled) { background: #aa0000; }
}
.btn-secondary {
background: #fff;
color: #333;
border: 1px solid #d0d0d0;
align-self: flex-start;
&:hover:not(:disabled) { background: #f5f5f5; }
}
.btn-ghost {
background: none;
color: #c62828;
border: 1px solid #f0c0c0;
font-size: 0.8rem;
padding: 0.2rem 0.65rem;
&:hover:not(:disabled) { background: #fff0f0; }
}
.btn-sm {
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
}
/* ── Button spinner ──────────────────────────────────────── */
.btn-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Actions bar ─────────────────────────────────────────── */
.settings-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
flex-wrap: wrap;
padding-top: 0.25rem;
}
.save-success {
color: #2e7d32;
font-size: 0.875rem;
font-weight: 500;
margin-right: auto;
}

View File

@@ -0,0 +1,103 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { SettingsService } from '../../core/services/settings.service';
import { AuthService } from '../../core/services/auth.service';
type TokenStatus = 'idle' | 'loading' | 'success' | 'error';
@Component({
selector: 'app-settings',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule],
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss',
})
export class SettingsComponent {
private readonly settingsService = inject(SettingsService);
private readonly authService = inject(AuthService);
private readonly router = inject(Router);
private readonly fb = inject(FormBuilder);
readonly token = this.authService.token;
readonly tokenStatus = signal<TokenStatus>('idle');
readonly tokenError = signal('');
readonly showPassword = signal(false);
readonly saveSuccess = signal(false);
readonly form = this.fb.group({
apiBaseUrl: [this.settingsService.settings().apiBaseUrl, Validators.required],
useMocks: [this.settingsService.settings().useMocks],
pollingIntervalMs: [
this.settingsService.settings().pollingIntervalMs,
[Validators.required, Validators.min(1000)],
],
endpointPlants: [this.settingsService.settings().endpointPlants, Validators.required],
endpointLiveData: [this.settingsService.settings().endpointLiveData, Validators.required],
endpointHistory: [this.settingsService.settings().endpointHistory, Validators.required],
authEndpoint: [this.settingsService.settings().authEndpoint, Validators.required],
username: [this.settingsService.settings().username],
password: [this.settingsService.settings().password],
});
get maskedToken(): string {
const t = this.token();
if (!t || t.length < 12) return t ?? '';
return `${t.substring(0, 8)}${t.substring(t.length - 4)}`;
}
save(): void {
if (this.form.invalid) return;
const v = this.form.getRawValue();
this.settingsService.save({
apiBaseUrl: v.apiBaseUrl!,
useMocks: v.useMocks!,
pollingIntervalMs: v.pollingIntervalMs!,
endpointPlants: v.endpointPlants!,
endpointLiveData: v.endpointLiveData!,
endpointHistory: v.endpointHistory!,
authEndpoint: v.authEndpoint!,
username: v.username ?? '',
password: v.password ?? '',
});
this.saveSuccess.set(true);
setTimeout(() => this.saveSuccess.set(false), 2500);
}
async fetchToken(): Promise<void> {
const v = this.form.getRawValue();
if (!v.username || !v.password) return;
// Aktuelle Formularwerte vor dem Login speichern
this.settingsService.save({
apiBaseUrl: v.apiBaseUrl!,
useMocks: v.useMocks!,
pollingIntervalMs: v.pollingIntervalMs!,
endpointPlants: v.endpointPlants!,
endpointLiveData: v.endpointLiveData!,
endpointHistory: v.endpointHistory!,
authEndpoint: v.authEndpoint!,
username: v.username,
password: v.password,
});
this.tokenStatus.set('loading');
this.tokenError.set('');
try {
await this.authService.login(v.username, v.password);
this.tokenStatus.set('success');
} catch (err: unknown) {
this.tokenStatus.set('error');
this.tokenError.set(err instanceof Error ? err.message : 'Token-Abruf fehlgeschlagen');
}
}
clearToken(): void {
this.authService.clearToken();
this.tokenStatus.set('idle');
}
goBack(): void {
this.router.navigate(['/dashboard']);
}
}

View File

@@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-footer',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<footer class="app-footer" role="contentinfo">
<p>&copy; {{ year }} Internorm PV Pulse. Alle Rechte vorbehalten.</p>
</footer>
`,
styles: [`
.app-footer {
padding: 1rem 1.5rem;
background: #cc0000;
color: rgba(255, 255, 255, 0.7);
font-size: 0.8rem;
text-align: center;
p { margin: 0; }
}
`],
})
export class FooterComponent {
readonly year = new Date().getFullYear();
}

View File

@@ -0,0 +1,72 @@
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 60px;
background: #cc0000;
color: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.brand {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: inherit;
.brand-icon { font-size: 1.5rem; }
.brand-name { font-size: 1.25rem; font-weight: 700; letter-spacing: 0.02em; }
}
.main-nav {
display: flex;
gap: 1.25rem;
flex: 1;
justify-content: center;
a {
color: rgba(255, 255, 255, 0.85);
text-decoration: none;
font-weight: 500;
padding: 0.25rem 0;
border-bottom: 2px solid transparent;
transition: color 0.2s, border-color 0.2s;
&:hover,
&.active {
color: #fff;
border-color: #ffeb3b;
}
}
}
.settings-btn {
background: none;
border: none;
cursor: pointer;
color: rgba(255, 255, 255, 0.8);
padding: 0.4rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s, background 0.2s;
flex-shrink: 0;
svg {
width: 22px;
height: 22px;
}
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.18);
}
&:focus-visible {
outline: 2px solid #ffeb3b;
outline-offset: 2px;
}
}

View File

@@ -0,0 +1,41 @@
import {
ChangeDetectionStrategy,
Component,
ViewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PinDialogComponent } from '../../shared/components/pin-dialog/pin-dialog.component';
@Component({
selector: 'app-header',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PinDialogComponent],
template: `
<header class="app-header" role="banner">
<a routerLink="/" class="brand" aria-label="PV Pulse Startseite">
<span class="brand-icon" aria-hidden="true">☀️</span>
<span class="brand-name">PV Pulse</span>
</a>
<nav class="main-nav" aria-label="Hauptnavigation">
<a routerLink="/dashboard">Dashboard</a>
<a routerLink="/plants">Anlagen</a>
</nav>
<button
class="settings-btn"
(click)="pinDialog.open()"
aria-label="Einstellungen öffnen"
title="Einstellungen"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7zm7.43-2.03c.04-.32.07-.65.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11.5c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.58 1.69-.98l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66z"/>
</svg>
</button>
</header>
<app-pin-dialog #pinDialog />
`,
styleUrl: './header.component.scss',
})
export class HeaderComponent {
@ViewChild('pinDialog') pinDialog!: PinDialogComponent;
}

View File

@@ -0,0 +1,35 @@
.sidebar {
width: 220px;
min-height: 100%;
background: #f5f5f5;
border-right: 1px solid #e0e0e0;
padding: 1rem 0;
ul {
list-style: none;
margin: 0;
padding: 0;
}
a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
color: #424242;
text-decoration: none;
font-weight: 500;
border-left: 3px solid transparent;
transition: background 0.15s, border-color 0.15s;
&:hover {
background: #eeeeee;
}
&.active {
background: #e3f2fd;
border-color: #1976d2;
color: #1565c0;
}
}
}

View File

@@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-sidebar',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, RouterLinkActive],
template: `
<nav class="sidebar" aria-label="Seitennavigation">
<ul role="list">
<li>
<a routerLink="/dashboard" routerLinkActive="active" aria-current="page">
<span aria-hidden="true">📊</span> Dashboard
</a>
</li>
<li>
<a routerLink="/plants" routerLinkActive="active" aria-current="page">
<span aria-hidden="true">🏭</span> Anlagen
</a>
</li>
</ul>
</nav>
`,
styleUrl: './sidebar.component.scss',
})
export class SidebarComponent {}

View File

@@ -0,0 +1,32 @@
import {
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
@Component({
selector: 'app-error-message',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="error-box" role="alert" aria-live="assertive">
<span class="error-icon" aria-hidden="true">⚠️</span>
<p class="error-text">{{ message() }}</p>
</div>
`,
styles: [`
.error-box {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: #fff3f3;
border: 1px solid #f44336;
border-radius: 8px;
color: #c62828;
}
.error-text { margin: 0; font-size: 0.95rem; }
`],
})
export class ErrorMessageComponent {
readonly message = input<string>('Ein Fehler ist aufgetreten.');
}

View File

@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-loading-spinner',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="spinner-wrapper" role="status" aria-label="Wird geladen…">
<div class="spinner"></div>
</div>
`,
styles: [`
.spinner-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-top-color: var(--color-primary, #1976d2);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`],
})
export class LoadingSpinnerComponent {}

View File

@@ -0,0 +1,217 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
inject,
QueryList,
signal,
ViewChild,
ViewChildren,
} from '@angular/core';
import { Router } from '@angular/router';
import { SettingsService } from '../../../core/services/settings.service';
@Component({
selector: 'app-pin-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<dialog #dialogEl class="pin-dialog" (click)="onBackdropClick($event)">
<div class="pin-dialog__panel" (click)="$event.stopPropagation()">
<div class="pin-dialog__icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7zm7.43-2.03c.04-.32.07-.65.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11.5c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.58 1.69-.98l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66z"/>
</svg>
</div>
<h2 class="pin-dialog__title">Einstellungen</h2>
<p class="pin-dialog__hint">Bitte 4-stelligen PIN eingeben</p>
<div class="pin-inputs" [class.shake]="hasError()">
@for (i of digits; track i) {
<input
#pinDigit
type="password"
inputmode="numeric"
maxlength="1"
class="pin-input"
autocomplete="off"
[attr.aria-label]="'PIN-Stelle ' + (i + 1)"
(input)="onInput($event, i)"
(keydown)="onKeydown($event, i)"
/>
}
</div>
@if (hasError()) {
<p class="pin-dialog__error" role="alert">Falscher PIN</p>
}
<button type="button" class="pin-dialog__cancel" (click)="close()">
Abbrechen
</button>
</div>
</dialog>
`,
styles: [`
.pin-dialog {
padding: 0;
border: none;
border-radius: 16px;
box-shadow: 0 24px 64px rgba(0,0,0,0.25);
width: min(380px, 92vw);
outline: none;
}
.pin-dialog::backdrop {
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(3px);
}
.pin-dialog__panel {
padding: 2rem 2rem 1.75rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.pin-dialog__icon {
color: #cc0000;
svg { width: 40px; height: 40px; }
}
.pin-dialog__title {
margin: 0;
font-size: 1.3rem;
font-weight: 700;
color: #1a1a1a;
}
.pin-dialog__hint {
margin: 0 0 0.75rem;
font-size: 0.9rem;
color: #666;
}
.pin-inputs {
display: flex;
gap: 0.75rem;
margin: 0.5rem 0 1rem;
}
.pin-inputs.shake {
animation: shake 0.4s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-6px); }
80% { transform: translateX(6px); }
}
.pin-input {
width: 52px;
height: 60px;
text-align: center;
font-size: 1.5rem;
font-weight: 700;
border: 2px solid #ddd;
border-radius: 10px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
caret-color: transparent;
color: #cc0000;
}
.pin-input:focus {
border-color: #cc0000;
box-shadow: 0 0 0 3px rgba(204,0,0,0.15);
}
.pin-dialog__error {
margin: 0;
color: #c62828;
font-size: 0.85rem;
font-weight: 500;
min-height: 1.2em;
}
.pin-dialog__cancel {
margin-top: 0.5rem;
background: none;
border: 1px solid #ddd;
border-radius: 6px;
padding: 0.5rem 1.5rem;
font-size: 0.9rem;
color: #555;
cursor: pointer;
transition: background 0.2s;
}
.pin-dialog__cancel:hover { background: #f5f5f5; }
`],
})
export class PinDialogComponent {
@ViewChild('dialogEl') private dialogEl!: ElementRef<HTMLDialogElement>;
@ViewChildren('pinDigit') private pinDigits!: QueryList<ElementRef<HTMLInputElement>>;
private readonly settingsService = inject(SettingsService);
private readonly router = inject(Router);
readonly hasError = signal(false);
readonly digits = [0, 1, 2, 3];
/** Öffnet den Dialog und fokussiert das erste Eingabefeld */
open(): void {
this.hasError.set(false);
this.dialogEl.nativeElement.showModal();
setTimeout(() => this.pinDigits.first?.nativeElement.focus(), 60);
}
/** Schließt den Dialog und leert alle Eingaben */
close(): void {
this.clearAll();
this.hasError.set(false);
this.dialogEl.nativeElement.close();
}
onBackdropClick(event: MouseEvent): void {
if (event.target === this.dialogEl.nativeElement) {
this.close();
}
}
onInput(event: Event, index: number): void {
const input = event.target as HTMLInputElement;
// Nur Ziffern erlauben
const digit = input.value.replace(/\D/g, '').slice(-1);
input.value = digit;
if (!digit) return;
if (index < 3) {
this.focusDigit(index + 1);
} else {
this.validate();
}
}
onKeydown(event: KeyboardEvent, index: number): void {
if (event.key === 'Backspace') {
const input = event.target as HTMLInputElement;
if (!input.value && index > 0) {
this.focusDigit(index - 1);
}
}
}
private focusDigit(index: number): void {
this.pinDigits.toArray()[index]?.nativeElement.focus();
}
private validate(): void {
const pin = this.pinDigits.toArray().map(d => d.nativeElement.value).join('');
if (this.settingsService.verifyPin(pin)) {
this.close();
this.router.navigate(['/settings']);
} else {
this.hasError.set(true);
this.clearAll();
setTimeout(() => {
this.hasError.set(false);
this.focusDigit(0);
}, 1500);
}
}
private clearAll(): void {
this.pinDigits?.forEach(d => (d.nativeElement.value = ''));
}
}

View File

@@ -0,0 +1,48 @@
import {
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
import { PlantStatus } from '../../../core/models/pv-plant.model';
@Component({
selector: 'app-status-badge',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<span
class="badge"
[class]="'badge--' + status()"
[attr.aria-label]="'Status: ' + statusLabel()"
>
{{ statusLabel() }}
</span>
`,
styles: [`
.badge {
display: inline-block;
padding: 0.2rem 0.65rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge--online { background: #e8f5e9; color: #2e7d32; }
.badge--offline { background: #eeeeee; color: #616161; }
.badge--warning { background: #fff8e1; color: #f57f17; }
.badge--error { background: #ffebee; color: #c62828; }
`],
})
export class StatusBadgeComponent {
readonly status = input.required<PlantStatus>();
statusLabel(): string {
const labels: Record<PlantStatus, string> = {
online: 'Online',
offline: 'Offline',
warning: 'Warnung',
error: 'Fehler',
};
return labels[this.status()];
}
}

6
src/app/shared/index.ts Normal file
View File

@@ -0,0 +1,6 @@
// Convenience barrel alle Shared-Exports an einem Ort
export { LoadingSpinnerComponent } from './components/loading-spinner/loading-spinner.component';
export { ErrorMessageComponent } from './components/error-message/error-message.component';
export { StatusBadgeComponent } from './components/status-badge/status-badge.component';
export { WattPipe } from './pipes/watt.pipe';
export { PinDialogComponent } from './components/pin-dialog/pin-dialog.component';

View File

@@ -0,0 +1,16 @@
import { Pipe, PipeTransform } from '@angular/core';
/** Formatiert Wattwerte: unter 1 kW → W, ab 1 kW → kW, ab 1 MW → MW */
@Pipe({ name: 'watt' })
export class WattPipe implements PipeTransform {
transform(valueKw: number, decimals = 1): string {
const w = valueKw * 1000;
if (w >= 1_000_000) {
return `${(valueKw / 1000).toFixed(decimals)} MW`;
}
if (w >= 1000) {
return `${valueKw.toFixed(decimals)} kW`;
}
return `${w.toFixed(0)} W`;
}
}

View File

@@ -0,0 +1,12 @@
export const environment = {
production: true,
useMocks: false,
apiBaseUrl: 'https://api.pv-pulse.internorm.com/api',
endpoints: {
plants: '/plants',
plantById: (id: string) => `/plants/${id}`,
liveData: (id: string) => `/plants/${id}/live`,
history: (id: string) => `/plants/${id}/history`,
},
pollingIntervalMs: 10000,
};

View File

@@ -0,0 +1,12 @@
export const environment = {
production: false,
useMocks: true, // ← auf false setzen + apiBaseUrl anpassen wenn echter REST-Service verfügbar
apiBaseUrl: 'http://localhost:3000/api',
endpoints: {
plants: '/plants',
plantById: (id: string) => `/plants/${id}`,
liveData: (id: string) => `/plants/${id}/live`,
history: (id: string) => `/plants/${id}/history`,
},
pollingIntervalMs: 5000,
};

13
src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PvPulseInternorm</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

6
src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

22
src/styles.scss Normal file
View File

@@ -0,0 +1,22 @@
/* ===== PV Pulse Global Styles ===== */
:root {
--color-primary: #1a237e;
--color-accent: #ffeb3b;
--color-bg: #f8f9fa;
--color-text: #212121;
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
font-family: 'Segoe UI', Roboto, Arial, sans-serif;
font-size: 16px;
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { line-height: 1.2; }