Initial commit: Angular PV Pulse App
This commit is contained in:
17
.editorconfig
Normal file
17
.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
12
.prettierrc
Normal file
12
.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
.vscode/launch.json
vendored
Normal file
20
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
.vscode/mcp.json
vendored
Normal file
9
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
// For more information, visit: https://angular.dev/ai/mcp
|
||||
"servers": {
|
||||
"angular-cli": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@angular/cli", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
42
.vscode/tasks.json
vendored
Normal file
42
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
59
README.md
Normal file
59
README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# PvPulseInternorm
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.1.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
84
angular.json
Normal file
84
angular.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"pv-pulse-internorm": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "pv-pulse-internorm:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "pv-pulse-internorm:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8606
package-lock.json
generated
Normal file
8606
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "pv-pulse-internorm",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.6.1",
|
||||
"dependencies": {
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.2.1",
|
||||
"@angular/cli": "^21.2.1",
|
||||
"@angular/compiler-cli": "^21.2.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
19
src/app/app.config.ts
Normal file
19
src/app/app.config.ts
Normal 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
10
src/app/app.html
Normal 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
64
src/app/app.routes.ts
Normal 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
16
src/app/app.scss
Normal 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
23
src/app/app.spec.ts
Normal 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
14
src/app/app.ts
Normal 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 {}
|
||||
18
src/app/core/guards/settings.guard.ts
Normal file
18
src/app/core/guards/settings.guard.ts
Normal 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']);
|
||||
};
|
||||
27
src/app/core/interceptors/api.interceptor.ts
Normal file
27
src/app/core/interceptors/api.interceptor.ts
Normal 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);
|
||||
};
|
||||
70
src/app/core/interceptors/mock-api.interceptor.ts
Normal file
70
src/app/core/interceptors/mock-api.interceptor.ts
Normal 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);
|
||||
};
|
||||
63
src/app/core/mocks/mock-history.data.ts
Normal file
63
src/app/core/mocks/mock-history.data.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
34
src/app/core/mocks/mock-live.data.ts
Normal file
34
src/app/core/mocks/mock-live.data.ts
Normal 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 ?? [],
|
||||
};
|
||||
}
|
||||
34
src/app/core/mocks/mock-plants.data.ts
Normal file
34
src/app/core/mocks/mock-plants.data.ts
Normal 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,
|
||||
}
|
||||
];
|
||||
18
src/app/core/models/api-response.model.ts
Normal file
18
src/app/core/models/api-response.model.ts
Normal 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;
|
||||
}
|
||||
35
src/app/core/models/pv-data.model.ts
Normal file
35
src/app/core/models/pv-data.model.ts
Normal 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[];
|
||||
}
|
||||
13
src/app/core/models/pv-plant.model.ts
Normal file
13
src/app/core/models/pv-plant.model.ts
Normal 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;
|
||||
}
|
||||
16
src/app/core/models/settings.model.ts
Normal file
16
src/app/core/models/settings.model.ts
Normal 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;
|
||||
}
|
||||
72
src/app/core/services/auth.service.ts
Normal file
72
src/app/core/services/auth.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src/app/core/services/live-data.service.ts
Normal file
50
src/app/core/services/live-data.service.ts
Normal 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 {}
|
||||
}
|
||||
27
src/app/core/services/pv-plant.service.ts
Normal file
27
src/app/core/services/pv-plant.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
78
src/app/core/services/settings.service.ts
Normal file
78
src/app/core/services/settings.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
48
src/app/features/dashboard/dashboard.component.html
Normal file
48
src/app/features/dashboard/dashboard.component.html
Normal 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>
|
||||
73
src/app/features/dashboard/dashboard.component.scss
Normal file
73
src/app/features/dashboard/dashboard.component.scss
Normal 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;
|
||||
}
|
||||
49
src/app/features/dashboard/dashboard.component.ts
Normal file
49
src/app/features/dashboard/dashboard.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
78
src/app/features/plant-detail/plant-detail.component.html
Normal file
78
src/app/features/plant-detail/plant-detail.component.html
Normal 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>
|
||||
65
src/app/features/plant-detail/plant-detail.component.scss
Normal file
65
src/app/features/plant-detail/plant-detail.component.scss
Normal 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;
|
||||
}
|
||||
73
src/app/features/plant-detail/plant-detail.component.ts
Normal file
73
src/app/features/plant-detail/plant-detail.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
102
src/app/features/plant-history/plant-history.component.html
Normal file
102
src/app/features/plant-history/plant-history.component.html
Normal 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>
|
||||
179
src/app/features/plant-history/plant-history.component.scss
Normal file
179
src/app/features/plant-history/plant-history.component.scss
Normal 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;
|
||||
}
|
||||
100
src/app/features/plant-history/plant-history.component.ts
Normal file
100
src/app/features/plant-history/plant-history.component.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/app/features/plant-list/plant-list.component.html
Normal file
42
src/app/features/plant-list/plant-list.component.html
Normal 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>
|
||||
36
src/app/features/plant-list/plant-list.component.scss
Normal file
36
src/app/features/plant-list/plant-list.component.scss
Normal 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; }
|
||||
37
src/app/features/plant-list/plant-list.component.ts
Normal file
37
src/app/features/plant-list/plant-list.component.ts
Normal 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); },
|
||||
});
|
||||
}
|
||||
}
|
||||
36
src/app/features/plant-shell/plant-shell.component.html
Normal file
36
src/app/features/plant-shell/plant-shell.component.html
Normal 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>
|
||||
}
|
||||
64
src/app/features/plant-shell/plant-shell.component.scss
Normal file
64
src/app/features/plant-shell/plant-shell.component.scss
Normal 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;
|
||||
}
|
||||
34
src/app/features/plant-shell/plant-shell.component.ts
Normal file
34
src/app/features/plant-shell/plant-shell.component.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
229
src/app/features/settings/settings.component.html
Normal file
229
src/app/features/settings/settings.component.html
Normal 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>{id}</code> als Platzhalter fü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>
|
||||
312
src/app/features/settings/settings.component.scss
Normal file
312
src/app/features/settings/settings.component.scss
Normal 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;
|
||||
}
|
||||
103
src/app/features/settings/settings.component.ts
Normal file
103
src/app/features/settings/settings.component.ts
Normal 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']);
|
||||
}
|
||||
}
|
||||
24
src/app/layout/footer/footer.component.ts
Normal file
24
src/app/layout/footer/footer.component.ts
Normal 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>© {{ 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();
|
||||
}
|
||||
72
src/app/layout/header/header.component.scss
Normal file
72
src/app/layout/header/header.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
41
src/app/layout/header/header.component.ts
Normal file
41
src/app/layout/header/header.component.ts
Normal 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;
|
||||
}
|
||||
35
src/app/layout/sidebar/sidebar.component.scss
Normal file
35
src/app/layout/sidebar/sidebar.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/app/layout/sidebar/sidebar.component.ts
Normal file
26
src/app/layout/sidebar/sidebar.component.ts
Normal 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 {}
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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 {}
|
||||
217
src/app/shared/components/pin-dialog/pin-dialog.component.ts
Normal file
217
src/app/shared/components/pin-dialog/pin-dialog.component.ts
Normal 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 = ''));
|
||||
}
|
||||
}
|
||||
@@ -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
6
src/app/shared/index.ts
Normal 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';
|
||||
16
src/app/shared/pipes/watt.pipe.ts
Normal file
16
src/app/shared/pipes/watt.pipe.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
12
src/environments/environment.prod.ts
Normal file
12
src/environments/environment.prod.ts
Normal 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,
|
||||
};
|
||||
12
src/environments/environment.ts
Normal file
12
src/environments/environment.ts
Normal 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
13
src/index.html
Normal 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
6
src/main.ts
Normal 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
22
src/styles.scss
Normal 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; }
|
||||
15
tsconfig.app.json
Normal file
15
tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
33
tsconfig.json
Normal file
33
tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
tsconfig.spec.json
Normal file
15
tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user