();
+ if (instance != null) services.AddSingleton(instance);
+ }
+
+ public void Dispose()
+ {
+ _logStreamService.Dispose();
+ _app?.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ }
+}
diff --git a/src/IBKRTrader.Hosting/wwwroot/css/app.css b/src/IBKRTrader.Hosting/wwwroot/css/app.css
new file mode 100644
index 0000000..fbdeaa7
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/css/app.css
@@ -0,0 +1,513 @@
+/* ─── IBKRTrader Web-UI — Design System (Avalonia / Polytrader Theme) ─── */
+
+:root {
+ --bg-app: #f3f4f6;
+ --bg-card: #ffffff;
+ --bg-toolbar: #fafafa;
+ --bg-statusbar: #fafafa;
+ --bg-hover: #f1f5f9;
+ --bg-selected: #e2e8f0;
+ --border-color: #dddddd;
+ --border-subtle: #e5e7eb;
+
+ --text-main: #1f2937;
+ --text-muted: #666666;
+ --text-light: #999999;
+
+ --color-primary: #2563eb;
+ --color-primary-hover: #1d4ed8;
+ --color-success: #16a34a;
+ --color-success-bg: #dcfce7;
+ --color-warning: #ea580c;
+ --color-warning-bg: #ffedd5;
+ --color-danger: #dc2626;
+ --color-danger-bg: #fee2e2;
+
+ --font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: var(--font-family);
+ font-size: 13px;
+ color: var(--text-main);
+ background-color: var(--bg-app);
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* ─── App Header & Navigation ─────────────────────────────────────────── */
+
+.app-header {
+ background: #ffffff;
+ border-bottom: 1px solid var(--border-color);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 16px;
+ height: 48px;
+ user-select: none;
+ flex-shrink: 0;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.brand-title {
+ font-size: 16px;
+ font-weight: 600;
+ letter-spacing: -0.2px;
+}
+
+.brand-badge {
+ font-size: 11px;
+ font-weight: 500;
+ padding: 2px 8px;
+ border-radius: 9999px;
+ background: var(--bg-app);
+ color: var(--text-muted);
+ border: 1px solid var(--border-subtle);
+}
+
+.badge-paper {
+ background: var(--color-success-bg);
+ color: var(--color-success);
+ border-color: #86efac;
+}
+
+.badge-live {
+ background: var(--color-danger-bg);
+ color: var(--color-danger);
+ border-color: #fca5a5;
+ font-weight: 600;
+}
+
+.nav-tabs {
+ display: flex;
+ gap: 4px;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+
+.nav-tab {
+ background: none;
+ border: none;
+ padding: 8px 14px;
+ font-family: inherit;
+ font-size: 13px;
+ color: var(--text-muted);
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: all 0.15s ease;
+ white-space: nowrap;
+}
+
+.nav-tab:hover {
+ color: var(--text-main);
+ background: var(--bg-hover);
+}
+
+.nav-tab.active {
+ color: var(--color-primary);
+ font-weight: 600;
+ border-bottom-color: var(--color-primary);
+ background: transparent;
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+/* ─── App Body Layout ─────────────────────────────────────────────────── */
+
+.app-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ position: relative;
+}
+
+.view-container {
+ display: none;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ background: var(--bg-card);
+}
+
+.view-container.active {
+ display: flex;
+}
+
+/* ─── Toolbars ────────────────────────────────────────────────────────── */
+
+.toolbar {
+ background: var(--bg-toolbar);
+ border-bottom: 1px solid var(--border-color);
+ padding: 6px 12px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-wrap: wrap;
+ flex-shrink: 0;
+}
+
+.toolbar-group {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+/* ─── Buttons & Controls ──────────────────────────────────────────────── */
+
+.btn {
+ background: #ffffff;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 5px 12px;
+ font-family: inherit;
+ font-size: 12px;
+ color: var(--text-main);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ transition: all 0.15s ease;
+ user-select: none;
+}
+
+.btn:hover {
+ background: var(--bg-hover);
+ border-color: #bbb;
+}
+
+.btn:active {
+ background: var(--bg-selected);
+}
+
+.btn-primary {
+ background: var(--color-primary);
+ border-color: var(--color-primary-hover);
+ color: #ffffff;
+}
+
+.btn-primary:hover {
+ background: var(--color-primary-hover);
+ border-color: #1e40af;
+}
+
+.btn-danger {
+ background: var(--color-danger);
+ border-color: #b91c1c;
+ color: #ffffff;
+}
+
+.btn-danger:hover {
+ background: #b91c1c;
+}
+
+.input, select {
+ background: #ffffff;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 4px 8px;
+ font-family: inherit;
+ font-size: 12px;
+ color: var(--text-main);
+ outline: none;
+}
+
+.input:focus, select:focus {
+ border-color: var(--color-primary);
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
+}
+
+.checkbox-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--text-main);
+ cursor: pointer;
+ user-select: none;
+}
+
+/* ─── KPI Panels & Cards (App.axaml style) ────────────────────────────── */
+
+.kpi-panel {
+ display: flex;
+ flex-wrap: wrap;
+ padding: 12px;
+ gap: 8px;
+ background: #ffffff;
+ border-bottom: 1px solid var(--border-subtle);
+ flex-shrink: 0;
+}
+
+.kpi-card {
+ background: #f5f5f5;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 10px 14px;
+ min-width: 160px;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.kpi-caption {
+ font-size: 11px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.kpi-value {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--text-main);
+ font-feature-settings: "tnum";
+}
+
+/* ─── DataGrid / Tables (Avalonia DataGrid style) ──────────────────────── */
+
+.table-container {
+ flex: 1;
+ overflow: auto;
+ background: #ffffff;
+ position: relative;
+}
+
+.datagrid {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+ text-align: left;
+}
+
+.datagrid th {
+ background: #fafafa;
+ color: var(--text-muted);
+ font-weight: 600;
+ padding: 6px 10px;
+ border-bottom: 1px solid var(--border-color);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ white-space: nowrap;
+ user-select: none;
+}
+
+.datagrid td {
+ padding: 5px 10px;
+ border-bottom: 1px solid #eeeeee;
+ color: var(--text-main);
+ white-space: nowrap;
+ font-feature-settings: "tnum";
+}
+
+.datagrid tbody tr {
+ cursor: pointer;
+ height: 26px;
+}
+
+.datagrid tbody tr:hover {
+ background: var(--bg-hover);
+}
+
+.datagrid tbody tr.selected {
+ background: #e0e7ff !important;
+ color: #1e3a8a;
+}
+
+.datagrid .text-right {
+ text-align: right;
+}
+
+.datagrid .text-center {
+ text-align: center;
+}
+
+/* Status Badges in Tables */
+.badge {
+ display: inline-block;
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+.badge-ok { background: #dcfce7; color: #15803d; }
+.badge-warn { background: #fef3c7; color: #b45309; }
+.badge-error { background: #fee2e2; color: #b91c1c; }
+
+/* ─── Log Viewer ──────────────────────────────────────────────────────── */
+
+.log-scroller {
+ flex: 1;
+ background: #1e1e1e;
+ color: #d4d4d4;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ line-height: 1.5;
+ padding: 8px 12px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.log-line {
+ padding: 1px 0;
+}
+.log-info { color: #96d296; }
+.log-warn { color: #ffbe3c; }
+.log-error { color: #ff5050; font-weight: 600; }
+
+/* ─── Settings Tree Form ──────────────────────────────────────────────── */
+
+.settings-scroll {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px 20px;
+}
+
+.settings-section {
+ background: #ffffff;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ margin-bottom: 12px;
+ overflow: hidden;
+}
+
+.settings-section-header {
+ background: #fafafa;
+ padding: 8px 14px;
+ font-weight: 600;
+ border-bottom: 1px solid var(--border-color);
+ cursor: pointer;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.settings-section-body {
+ padding: 10px 14px;
+ display: grid;
+ grid-template-columns: 240px 1fr;
+ row-gap: 8px;
+ column-gap: 16px;
+ align-items: center;
+}
+
+.settings-label {
+ font-weight: 500;
+ color: var(--text-main);
+}
+.settings-desc {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-top: 1px;
+}
+
+/* ─── Statusbar ───────────────────────────────────────────────────────── */
+
+.app-statusbar {
+ background: var(--bg-statusbar);
+ border-top: 1px solid var(--border-color);
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 12px;
+ font-size: 11px;
+ color: var(--text-muted);
+ user-select: none;
+ flex-shrink: 0;
+}
+
+/* ─── Login Overlay & Dialog ──────────────────────────────────────────── */
+
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ backdrop-filter: blur(2px);
+}
+
+.login-card {
+ background: #ffffff;
+ border-radius: 8px;
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
+ width: 100%;
+ max-width: 380px;
+ padding: 28px;
+ border: 1px solid var(--border-color);
+}
+
+.login-title {
+ font-size: 18px;
+ font-weight: 600;
+ margin-bottom: 6px;
+ text-align: center;
+}
+
+.login-subtitle {
+ font-size: 12px;
+ color: var(--text-muted);
+ text-align: center;
+ margin-bottom: 20px;
+}
+
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-label {
+ display: block;
+ font-size: 12px;
+ font-weight: 500;
+ margin-bottom: 6px;
+}
+
+.form-input {
+ width: 100%;
+ padding: 8px 10px;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ font-size: 13px;
+ outline: none;
+}
+
+.form-input:focus {
+ border-color: var(--color-primary);
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
+}
+
+.login-error {
+ color: var(--color-danger);
+ font-size: 12px;
+ margin-top: 8px;
+ min-height: 18px;
+ text-align: center;
+}
diff --git a/src/IBKRTrader.Hosting/wwwroot/index.html b/src/IBKRTrader.Hosting/wwwroot/index.html
new file mode 100644
index 0000000..84bd1ae
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/index.html
@@ -0,0 +1,68 @@
+
+
+
+
+
+ IBKRTrader
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
IBKRTrader Login
+
Geben Sie das Zugriffs-Token ein (settings.json > Webserver > AuthToken)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/IBKRTrader.Hosting/wwwroot/js/api.js b/src/IBKRTrader.Hosting/wwwroot/js/api.js
new file mode 100644
index 0000000..9a0747e
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/js/api.js
@@ -0,0 +1,77 @@
+// ─── API Client mit Authentifizierungs-Interceptor ───
+
+export class ApiClient {
+ constructor() {
+ this.token = sessionStorage.getItem('ibkr_token') || '';
+ this.onUnauthorized = null;
+ }
+
+ setToken(token) {
+ this.token = token;
+ if (token) {
+ sessionStorage.setItem('ibkr_token', token);
+ } else {
+ sessionStorage.removeItem('ibkr_token');
+ }
+ }
+
+ hasToken() {
+ return !!this.token;
+ }
+
+ async request(url, options = {}) {
+ const headers = {
+ 'Accept': 'application/json',
+ ...options.headers
+ };
+
+ if (this.token) {
+ headers['Authorization'] = `Bearer ${this.token}`;
+ }
+
+ if (options.body && typeof options.body === 'object' && !(options.body instanceof FormData)) {
+ headers['Content-Type'] = 'application/json';
+ options.body = JSON.stringify(options.body);
+ }
+
+ try {
+ const response = await fetch(url, { ...options, headers });
+
+ if (response.status === 401) {
+ if (this.onUnauthorized) {
+ this.onUnauthorized();
+ }
+ throw new Error('Authentifizierung erforderlich (401)');
+ }
+
+ if (!response.ok) {
+ let errMessage = `HTTP ${response.status}`;
+ try {
+ const errData = await response.json();
+ if (errData.error || errData.message) {
+ errMessage = errData.error || errData.message;
+ }
+ } catch (_) {}
+ throw new Error(errMessage);
+ }
+
+ const contentType = response.headers.get('content-type') || '';
+ if (contentType.includes('application/json')) {
+ return await response.json();
+ }
+ return response;
+ } catch (err) {
+ throw err;
+ }
+ }
+
+ get(url) {
+ return this.request(url, { method: 'GET' });
+ }
+
+ post(url, body) {
+ return this.request(url, { method: 'POST', body });
+ }
+}
+
+export const api = new ApiClient();
diff --git a/src/IBKRTrader.Hosting/wwwroot/js/app.js b/src/IBKRTrader.Hosting/wwwroot/js/app.js
new file mode 100644
index 0000000..26535b0
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/js/app.js
@@ -0,0 +1,159 @@
+// ─── Main Web-UI Application Router ───
+
+import { api } from './api.js';
+import { auth } from './auth.js';
+import { DashboardView } from './views/dashboard.js';
+import { WorkersView } from './views/workers.js';
+import { PositionsView } from './views/positions.js';
+import { OrdersView } from './views/orders.js';
+import { CongressTradingView } from './views/congress.js';
+import { AccountingView } from './views/accounting.js';
+import { SupervisorView } from './views/supervisor.js';
+import { LogsView } from './views/logs.js';
+import { SettingsView } from './views/settings.js';
+
+class App {
+ constructor() {
+ this.container = document.getElementById('view-container');
+ this.statusMsgEl = document.getElementById('status-bar-message');
+ this.clockEl = document.getElementById('status-bar-clock');
+ this.modeBadgeEl = document.getElementById('app-mode-badge');
+ this.currentView = null;
+ this.currentViewName = null;
+
+ this.views = {
+ dashboard: DashboardView,
+ workers: WorkersView,
+ positions: PositionsView,
+ orders: OrdersView,
+ congress: CongressTradingView,
+ accounting: AccountingView,
+ supervisor: SupervisorView,
+ logs: LogsView,
+ settings: SettingsView
+ };
+
+ this.setStatus = this.setStatus.bind(this);
+ }
+
+ init() {
+ this.startClock();
+ this.wireNav();
+
+ auth.onLoginSuccess = async () => {
+ await this.updateStatusBadge();
+ await this.switchView(this.currentViewName || 'dashboard');
+ };
+
+ // Initial auth check
+ auth.checkInitialAuth().then(async (ok) => {
+ if (ok) {
+ await this.updateStatusBadge();
+ const hash = window.location.hash.replace('#', '') || 'dashboard';
+ const initialView = this.views[hash] ? hash : 'dashboard';
+ await this.switchView(initialView);
+ }
+ });
+
+ window.addEventListener('hashchange', () => {
+ const viewName = window.location.hash.replace('#', '');
+ if (this.views[viewName] && viewName !== this.currentViewName) {
+ this.switchView(viewName);
+ }
+ });
+ }
+
+ startClock() {
+ const updateClock = () => {
+ const now = new Date();
+ const h = String(now.getUTCHours()).padStart(2, '0');
+ const m = String(now.getUTCMinutes()).padStart(2, '0');
+ const s = String(now.getUTCSeconds()).padStart(2, '0');
+ if (this.clockEl) {
+ this.clockEl.textContent = `${h}:${m}:${s} UTC`;
+ }
+ };
+ updateClock();
+ setInterval(updateClock, 1000);
+ }
+
+ wireNav() {
+ const navTabs = document.querySelectorAll('#main-nav .nav-tab');
+ navTabs.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const viewName = btn.getAttribute('data-view');
+ if (viewName) {
+ window.location.hash = viewName;
+ this.switchView(viewName);
+ }
+ });
+ });
+ }
+
+ setStatus(msg) {
+ if (this.statusMsgEl) {
+ this.statusMsgEl.textContent = msg || 'Bereit.';
+ }
+ }
+
+ async updateStatusBadge() {
+ try {
+ const status = await api.get('/api/status');
+ if (status && this.modeBadgeEl) {
+ const mode = status.mode || 'PAPER';
+ this.modeBadgeEl.textContent = mode.toUpperCase();
+ if (mode.toLowerCase() === 'live') {
+ this.modeBadgeEl.className = 'brand-badge badge-live';
+ } else {
+ this.modeBadgeEl.className = 'brand-badge badge-paper';
+ }
+ }
+ } catch (_) {}
+ }
+
+ async switchView(viewName) {
+ if (!this.views[viewName]) {
+ viewName = 'dashboard';
+ }
+
+ if (this.currentView && typeof this.currentView.destroy === 'function') {
+ try {
+ this.currentView.destroy();
+ } catch (err) {
+ console.warn('Fehler bei cleanup des vorherigen Views:', err);
+ }
+ }
+
+ // Update nav tabs highlight
+ const navTabs = document.querySelectorAll('#main-nav .nav-tab');
+ navTabs.forEach(btn => {
+ if (btn.getAttribute('data-view') === viewName) {
+ btn.classList.add('active');
+ } else {
+ btn.classList.remove('active');
+ }
+ });
+
+ this.currentViewName = viewName;
+ this.container.innerHTML = 'Lade Ansicht …
';
+
+ const ViewClass = this.views[viewName];
+ this.currentView = new ViewClass(this.container, this.setStatus);
+
+ try {
+ await this.currentView.render();
+ } catch (err) {
+ console.error(`Fehler beim Rendern von View '${viewName}':`, err);
+ this.container.innerHTML = `
+
+
Fehler beim Laden der Ansicht
+
${err.message}
+
+ `;
+ }
+ }
+}
+
+// Start application
+const app = new App();
+document.addEventListener('DOMContentLoaded', () => app.init());
diff --git a/src/IBKRTrader.Hosting/wwwroot/js/auth.js b/src/IBKRTrader.Hosting/wwwroot/js/auth.js
new file mode 100644
index 0000000..ec8fe7e
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/js/auth.js
@@ -0,0 +1,101 @@
+// ─── Authentifizierungs-Verwaltung ───
+
+import { api } from './api.js';
+
+export class AuthManager {
+ constructor() {
+ this.onLoginSuccess = null;
+ this.modal = document.getElementById('login-modal');
+ this.tokenInput = document.getElementById('login-token-input');
+ this.errorText = document.getElementById('login-error-text');
+ this.submitBtn = document.getElementById('login-submit-btn');
+ this.logoutBtn = document.getElementById('logout-btn');
+
+ this.wireEvents();
+ }
+
+ wireEvents() {
+ if (this.submitBtn) {
+ this.submitBtn.addEventListener('click', () => this.handleLogin());
+ }
+ if (this.tokenInput) {
+ this.tokenInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') this.handleLogin();
+ });
+ }
+ if (this.logoutBtn) {
+ this.logoutBtn.addEventListener('click', () => this.handleLogout());
+ }
+
+ api.onUnauthorized = () => this.showLoginModal();
+ }
+
+ showLoginModal() {
+ if (this.modal) {
+ this.modal.style.display = 'flex';
+ this.errorText.textContent = '';
+ if (this.tokenInput) {
+ this.tokenInput.value = '';
+ setTimeout(() => this.tokenInput.focus(), 50);
+ }
+ }
+ }
+
+ hideLoginModal() {
+ if (this.modal) {
+ this.modal.style.display = 'none';
+ this.errorText.textContent = '';
+ }
+ }
+
+ async checkInitialAuth() {
+ try {
+ const status = await api.get('/api/auth/status');
+ if (status && status.authenticated) {
+ this.hideLoginModal();
+ return true;
+ }
+ } catch (_) {}
+
+ this.showLoginModal();
+ return false;
+ }
+
+ async handleLogin() {
+ const token = this.tokenInput ? this.tokenInput.value.trim() : '';
+ if (!token) {
+ this.errorText.textContent = 'Bitte Auth-Token eingeben.';
+ return;
+ }
+
+ this.submitBtn.disabled = true;
+ this.errorText.textContent = 'Prüfe Token …';
+
+ try {
+ const res = await api.post('/api/auth/login', { token });
+ if (res && res.ok) {
+ api.setToken(res.token || token);
+ this.hideLoginModal();
+ if (this.onLoginSuccess) {
+ this.onLoginSuccess();
+ }
+ } else {
+ this.errorText.textContent = res.error || 'Ungültiger Token.';
+ }
+ } catch (err) {
+ this.errorText.textContent = err.message || 'Anmeldung fehlgeschlagen.';
+ } finally {
+ this.submitBtn.disabled = false;
+ }
+ }
+
+ async handleLogout() {
+ try {
+ await api.post('/api/auth/logout');
+ } catch (_) {}
+ api.setToken('');
+ this.showLoginModal();
+ }
+}
+
+export const auth = new AuthManager();
diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/accounting.js b/src/IBKRTrader.Hosting/wwwroot/js/views/accounting.js
new file mode 100644
index 0000000..2acd23d
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/js/views/accounting.js
@@ -0,0 +1,392 @@
+// ─── Accounting View ───
+
+import { api } from '../api.js';
+
+export class AccountingView {
+ constructor(container, setStatus) {
+ this.container = container;
+ this.setStatus = setStatus;
+ this.currentSubTab = 'overview';
+ this.accounts = [];
+ }
+
+ async render() {
+ const today = new Date().toISOString().split('T')[0];
+ const monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().split('T')[0];
+
+ this.container.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // Bind filters
+ document.getElementById('acc-refresh-btn').addEventListener('click', () => this.refreshCurrentSubTab());
+ document.getElementById('acc-account').addEventListener('change', () => this.refreshCurrentSubTab());
+ document.getElementById('acc-currency').addEventListener('change', () => this.refreshCurrentSubTab());
+ document.getElementById('acc-from').addEventListener('change', () => this.refreshCurrentSubTab());
+ document.getElementById('acc-to').addEventListener('change', () => this.refreshCurrentSubTab());
+
+ // Subtab switching
+ const subtabs = this.container.querySelectorAll('[data-subtab]');
+ subtabs.forEach(tab => {
+ tab.addEventListener('click', () => {
+ subtabs.forEach(t => t.classList.remove('active'));
+ tab.classList.add('active');
+ this.currentSubTab = tab.getAttribute('data-subtab');
+ this.renderSubTab();
+ });
+ });
+
+ await this.loadAccounts();
+ await this.renderSubTab();
+ }
+
+ async loadAccounts() {
+ try {
+ const accounts = await api.get('/api/modules/accounting/accounts');
+ if (Array.isArray(accounts)) {
+ this.accounts = accounts;
+ const select = document.getElementById('acc-account');
+ if (select) {
+ select.innerHTML = '' +
+ accounts.map(a => ``).join('');
+ }
+ }
+ } catch (e) {
+ console.warn('Fehler beim Laden der Konten:', e);
+ }
+ }
+
+ getFilters() {
+ const from = document.getElementById('acc-from')?.value || '';
+ const to = document.getElementById('acc-to')?.value || '';
+ const account = document.getElementById('acc-account')?.value || '(alle)';
+ const currency = document.getElementById('acc-currency')?.value || 'USD';
+ return { from, to, account, currency };
+ }
+
+ async renderSubTab() {
+ const sub = document.getElementById('acc-subcontent');
+ if (!sub) return;
+
+ if (this.currentSubTab === 'overview') {
+ sub.innerHTML = `
+
+
+
+
+ Monatsvergleich
+
+
+
+
+ | Monat |
+ Anfangssaldo |
+ Einzahlungen |
+ Auszahlungen |
+ Volumen |
+ Fees |
+ Ergebnis |
+ Endsaldo |
+
+
+
+ | Lade Monatsvergleich … |
+
+
+
+ `;
+ await this.loadOverview();
+ } else if (this.currentSubTab === 'ledger') {
+ sub.innerHTML = `
+
+
+
+
+ | Zeit (UTC) |
+ Konto |
+ Typ |
+ Side |
+ Symbol |
+ Währung |
+ Menge |
+ Preis |
+ Brutto |
+ Fee |
+ Netto |
+ Transaktion |
+
+
+
+ | Lade Ledger … |
+
+
+
+ `;
+ await this.loadLedger();
+ } else if (this.currentSubTab === 'tax') {
+ sub.innerHTML = `
+
+
Steuerliche Einordnung
+
+ Die steuerliche Einordnung ist noch offen (Jurisdiktion nicht festgelegt).
+
+
+ Der neutrale Ledger und die Periodenabrechnung sind davon unabhängig gültig. Eine konkrete Steuerschicht (z. B. DE-Kapitalertragsteuer oder US Form 8949 / Schedule D) wird hier später als klar dokumentierte, prüfbare Rechenschicht ergänzt.
+
+
+ Hinweis: Dies ist keine Steuerberatung. Alle Daten basieren auf den Buchungseinträgen des Ledgers.
+
+
+ `;
+ } else if (this.currentSubTab === 'export') {
+ const f = this.getFilters();
+ const params = new URLSearchParams({ account: f.account, from: f.from, to: f.to, currency: f.currency });
+ sub.innerHTML = `
+
+
Abrechnung & Export
+
+ Exportiert die aktuelle Auswahl (Zeitraum: ${f.from} bis ${f.to} | Konto: ${f.account} | Währung: ${f.currency}):
+
+
+
+ `;
+ } else if (this.currentSubTab === 'runs') {
+ sub.innerHTML = `
+
+
+
+
+
+ | Konto |
+ Start |
+ Ende |
+ Backfill |
+ Neu |
+ Duplikate |
+ OK |
+ Delta |
+ Meldung |
+
+
+
+ | Lade Abruf-Historie … |
+
+
+
+ `;
+ document.getElementById('acc-ingest-inc-btn').addEventListener('click', () => this.runIngest(false));
+ document.getElementById('acc-ingest-backfill-btn').addEventListener('click', () => this.runIngest(true));
+ await this.loadRuns();
+ }
+ }
+
+ async refreshCurrentSubTab() {
+ await this.renderSubTab();
+ }
+
+ async loadOverview() {
+ const f = this.getFilters();
+ this.setStatus('Lade Accounting-Übersicht …');
+ try {
+ const params = new URLSearchParams({ account: f.account, from: f.from, to: f.to, currency: f.currency });
+ const data = await api.get(`/api/modules/accounting/overview?${params}`);
+ if (!data) return;
+
+ const s = data.statement || {};
+ const curr = data.currencyCode || f.currency;
+
+ const F = (v) => Number(v || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' + curr;
+
+ const netEl = document.getElementById('kpi-acc-net');
+ if (netEl) {
+ netEl.textContent = F(s.netTradingResult);
+ netEl.style.color = (s.netTradingResult >= 0) ? 'var(--color-success)' : 'var(--color-danger)';
+ }
+ document.getElementById('kpi-acc-vol').textContent = F(s.tradeVolume);
+ document.getElementById('kpi-acc-div').textContent = F(s.dividends);
+ document.getElementById('kpi-acc-fees').textContent = F(s.fees);
+ document.getElementById('kpi-acc-balance').textContent = F(s.closingBalance);
+ document.getElementById('kpi-acc-counts').textContent = `${s.tradeCount || 0} / ${s.entryCount || 0}`;
+
+ const noteEl = document.getElementById('acc-currency-note');
+ if (noteEl) noteEl.textContent = data.currencyNote || '';
+
+ const tbody = document.getElementById('acc-monthly-tbody');
+ const monthly = data.monthly || [];
+ if (tbody) {
+ if (monthly.length === 0) {
+ tbody.innerHTML = `| Keine Monatsdaten im gewählten Zeitraum. |
`;
+ } else {
+ tbody.innerHTML = monthly.map(m => `
+
+ | ${m.month} |
+ ${F(m.openingBalance)} |
+ ${F(m.deposits)} |
+ ${F(m.withdrawals)} |
+ ${F(m.tradeVolume)} |
+ ${F(m.fees)} |
+ ${F(m.netTradingResult)} |
+ ${F(m.closingBalance)} |
+
+ `).join('');
+ }
+ }
+
+ this.setStatus(`Übersicht aktualisiert (${monthly.length} Monate).`);
+ } catch (e) {
+ console.error(e);
+ this.setStatus(`Fehler: ${e.message}`);
+ }
+ }
+
+ async loadLedger() {
+ const f = this.getFilters();
+ this.setStatus('Lade Ledger-Einträge …');
+ try {
+ const params = new URLSearchParams({ account: f.account, from: f.from, to: f.to, limit: 1000 });
+ const entries = await api.get(`/api/modules/accounting/ledger?${params}`);
+ const tbody = document.getElementById('acc-ledger-tbody');
+ if (!tbody) return;
+
+ if (!Array.isArray(entries) || entries.length === 0) {
+ tbody.innerHTML = `| Keine Ledger-Einträge im gewählten Zeitraum. |
`;
+ } else {
+ tbody.innerHTML = entries.map(e => `
+
+ | ${e.timestamp} |
+ ${e.accountId} |
+ ${e.eventType} |
+ ${e.side || '–'} |
+ ${e.symbol || '–'} |
+ ${e.currency || '–'} |
+ ${e.quantity !== null && e.quantity !== undefined ? Number(e.quantity).toLocaleString('de-DE') : '–'} |
+ ${e.priceNative !== null && e.priceNative !== undefined ? Number(e.priceNative).toLocaleString('de-DE', { minimumFractionDigits: 2 }) : '–'} |
+ ${Number(e.grossBase || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })} |
+ ${Number(e.feeBase || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })} |
+ ${Number(e.netBase || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })} |
+ ${e.transactionId || '–'} |
+
+ `).join('');
+ }
+
+ this.setStatus(`Ledger geladen (${entries ? entries.length : 0} Einträge).`);
+ } catch (e) {
+ console.error(e);
+ this.setStatus(`Fehler: ${e.message}`);
+ }
+ }
+
+ async loadRuns() {
+ const f = this.getFilters();
+ this.setStatus('Lade Abruf-Historie …');
+ try {
+ const params = new URLSearchParams({ account: f.account, limit: 100 });
+ const runs = await api.get(`/api/modules/accounting/runs?${params}`);
+ const tbody = document.getElementById('acc-runs-tbody');
+ if (!tbody) return;
+
+ if (!Array.isArray(runs) || runs.length === 0) {
+ tbody.innerHTML = `| Keine Abrufe vorhanden. |
`;
+ } else {
+ tbody.innerHTML = runs.map(r => `
+
+ | ${r.accountId} |
+ ${r.startedAt} |
+ ${r.finishedAt} |
+ ${r.backfill ? 'Ja' : 'Nein'} |
+ ${Number(r.newEntries || 0).toLocaleString('de-DE')} |
+ ${Number(r.duplicateEntries || 0).toLocaleString('de-DE')} |
+ ${r.success ? 'OK' : 'Fehler'} |
+ ${Number(r.balanceDeltaBase || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })} |
+ ${r.message || '–'} |
+
+ `).join('');
+ }
+
+ this.setStatus(`Abrufe geladen (${runs ? runs.length : 0} Läufe).`);
+ } catch (e) {
+ console.error(e);
+ this.setStatus(`Fehler: ${e.message}`);
+ }
+ }
+
+ async runIngest(backfill) {
+ const msgEl = document.getElementById('acc-ingest-msg');
+ if (msgEl) msgEl.textContent = backfill ? 'Führe Backfill durch …' : 'Führe inkrementellen Abruf durch …';
+ this.setStatus('Starte Ingest …');
+
+ try {
+ const res = await api.post(`/api/modules/accounting/ingest?backfill=${backfill}`);
+ if (res && res.success) {
+ if (msgEl) msgEl.textContent = res.message || 'Abruf abgeschlossen.';
+ } else {
+ if (msgEl) msgEl.textContent = `Fehler: ${res?.message || 'Unbekannt'}`;
+ }
+ await this.loadRuns();
+ } catch (e) {
+ if (msgEl) msgEl.textContent = `Fehler: ${e.message}`;
+ }
+ }
+}
diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/congress.js b/src/IBKRTrader.Hosting/wwwroot/js/views/congress.js
new file mode 100644
index 0000000..f68125b
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/js/views/congress.js
@@ -0,0 +1,114 @@
+// ─── Congress Trading View ───
+
+import { api } from '../api.js';
+
+export class CongressTradingView {
+ constructor(container, setStatus) {
+ this.container = container;
+ this.setStatus = setStatus;
+ }
+
+ async render() {
+ this.container.innerHTML = `
+
+
+
+
+
+
+ Offene Positionen (Modul CT)
+
+
+
+
+ | Symbol |
+ Stück |
+ Ø-Kurs |
+ Wert (USD) |
+
+
+
+ | Lade Daten … |
+
+
+
+ `;
+
+ document.getElementById('ct-refresh-btn').addEventListener('click', () => this.refresh());
+ document.getElementById('ct-scrape-btn').addEventListener('click', () => this.triggerScrape());
+
+ await this.refresh();
+ }
+
+ async refresh() {
+ this.setStatus('Lade Congress-Trading-Daten …');
+ try {
+ const data = await api.get('/api/modules/congresstrading/summary');
+ if (!data) return;
+
+ document.getElementById('ct-kpi-trades').textContent = (data.trades ?? 0).toLocaleString('de-DE');
+ document.getElementById('ct-kpi-members').textContent = (data.members ?? 0).toLocaleString('de-DE');
+
+ const positions = data.positions || [];
+ document.getElementById('ct-kpi-positions').textContent = positions.length.toLocaleString('de-DE');
+
+ const totalNotional = positions.reduce((sum, p) => sum + (Number(p.notional) || 0), 0);
+ document.getElementById('ct-kpi-notional').textContent =
+ totalNotional.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' $';
+
+ const tbody = document.getElementById('ct-tbody');
+ if (positions.length === 0) {
+ tbody.innerHTML = `| Keine offenen Positionen im Buch CongressTrading. |
`;
+ } else {
+ tbody.innerHTML = positions.map(p => `
+
+ | ${p.symbol} |
+ ${Number(p.quantity).toLocaleString('de-DE')} |
+ ${Number(p.avgPrice).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $ |
+ ${Number(p.notional).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $ |
+
+ `).join('');
+ }
+
+ const now = new Date().toLocaleTimeString('de-DE');
+ document.getElementById('ct-status-text').textContent = `Stand: ${now} (${positions.length} Positionen)`;
+ this.setStatus(`Congress Trading aktualisiert um ${now}.`);
+ } catch (e) {
+ console.error(e);
+ this.setStatus(`Fehler: ${e.message}`);
+ }
+ }
+
+ async triggerScrape() {
+ const btn = document.getElementById('ct-scrape-btn');
+ btn.disabled = true;
+ btn.textContent = '⏳ Starte Scrape …';
+ this.setStatus('Löse CT-Scrape-Worker aus …');
+
+ try {
+ const res = await api.post('/api/modules/congresstrading/scrape');
+ if (res && res.success) {
+ alert(res.message || 'Scrape erfolgreich ausgelöst.');
+ } else {
+ alert(`Scrape fehlgeschlagen: ${res?.message || 'Unbekannter Fehler'}`);
+ }
+ await this.refresh();
+ } catch (e) {
+ alert(`Fehler beim Starten des Scrapes: ${e.message}`);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = '⚡ Scrape jetzt';
+ }
+ }
+}
diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/dashboard.js b/src/IBKRTrader.Hosting/wwwroot/js/views/dashboard.js
new file mode 100644
index 0000000..4642629
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/js/views/dashboard.js
@@ -0,0 +1,92 @@
+// ─── Dashboard View ───
+
+import { api } from '../api.js';
+
+export class DashboardView {
+ constructor(container, setStatus) {
+ this.container = container;
+ this.setStatus = setStatus;
+ }
+
+ async render() {
+ this.container.innerHTML = `
+
+
+
+
+
+
+
+
+ | Modul |
+ DB-Präfix |
+ Status / Blocker |
+
+
+
+ | Lade Daten … |
+
+
+
+ `;
+
+ document.getElementById('dash-refresh-btn').addEventListener('click', () => this.refresh());
+ await this.refresh();
+ }
+
+ async refresh() {
+ this.setStatus('Lade Dashboard …');
+ try {
+ const data = await api.get('/api/dashboard');
+ if (!data) return;
+
+ // Mode
+ const modeEl = document.getElementById('dash-mode-text');
+ if (modeEl && data.trading) {
+ modeEl.textContent = `Trading: ${data.trading.mode} – ${data.trading.tradingEnabled ? 'AKTIV' : 'inaktiv'}`;
+ modeEl.style.color = data.trading.tradingEnabled ? 'var(--color-success)' : 'var(--text-muted)';
+ }
+
+ // KPIs
+ if (data.kpis) {
+ document.getElementById('kpi-open').textContent = data.kpis.openPositions ?? '0';
+ document.getElementById('kpi-exposure').textContent = Number(data.kpis.totalExposure || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+ document.getElementById('kpi-trades').textContent = data.kpis.totalTrades ?? '0';
+ document.getElementById('kpi-workers').textContent = data.kpis.workerCount ?? '0';
+ }
+
+ // Modules
+ const tbody = document.getElementById('dash-modules-tbody');
+ if (tbody && data.modules) {
+ if (data.modules.length === 0) {
+ tbody.innerHTML = `| Keine Module geladen. |
`;
+ } else {
+ tbody.innerHTML = data.modules.map(m => `
+
+ | ${m.name} |
+ ${m.dbPrefix} |
+ ${m.blocker === 'aktivierbar' ? 'aktivierbar' : `${m.blocker}`} |
+
+ `).join('');
+ }
+ }
+
+ const now = new Date().toLocaleTimeString('de-DE');
+ document.getElementById('dash-status-text').textContent = `Aktualisiert: ${now}`;
+ this.setStatus(`Dashboard bereit | ${now}`);
+ } catch (err) {
+ this.setStatus(`Fehler: ${err.message}`);
+ }
+ }
+}
diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/logs.js b/src/IBKRTrader.Hosting/wwwroot/js/views/logs.js
new file mode 100644
index 0000000..9ef4e8c
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/js/views/logs.js
@@ -0,0 +1,143 @@
+// ─── Logs View (Server-Sent Events) ───
+
+import { api } from '../api.js';
+
+export class LogsView {
+ constructor(container, setStatus) {
+ this.container = container;
+ this.setStatus = setStatus;
+ this.eventSource = null;
+ this.filter = '';
+ this.entries = [];
+ this.maxLines = 5000;
+ }
+
+ render() {
+ this.container.innerHTML = `
+
+
+
+ `;
+
+ document.getElementById('log-filter-input').addEventListener('input', (e) => {
+ this.filter = e.target.value.toLowerCase();
+ this.reapplyFilter();
+ });
+
+ document.getElementById('log-clear-btn').addEventListener('click', () => {
+ this.entries = [];
+ document.getElementById('log-scroller-el').innerHTML = '';
+ this.updateCounter();
+ this.setStatus('Logs geleert.');
+ });
+
+ document.getElementById('log-copy-btn').addEventListener('click', async () => {
+ const text = this.entries.map(e => e.formatted || e.message).join('\n');
+ try {
+ await navigator.clipboard.writeText(text);
+ this.setStatus(`${this.entries.length} Zeilen in die Zwischenablage kopiert.`);
+ } catch (_) {
+ this.setStatus('Kopieren fehlgeschlagen.');
+ }
+ });
+
+ this.connectSse();
+ }
+
+ connectSse() {
+ if (this.eventSource) {
+ this.eventSource.close();
+ }
+
+ const token = api.token;
+ const url = `/api/logs/stream${token ? `?token=${encodeURIComponent(token)}` : ''}`;
+
+ this.setStatus('Verbinde mit Live-Log-Stream (SSE) …');
+
+ this.eventSource = new EventSource(url);
+
+ this.eventSource.onopen = () => {
+ this.setStatus('Live-Log verbunden.');
+ };
+
+ this.eventSource.onmessage = (event) => {
+ try {
+ const entry = JSON.parse(event.data);
+ this.appendEntry(entry);
+ } catch (_) {}
+ };
+
+ this.eventSource.onerror = () => {
+ this.setStatus('Live-Log Verbindung unterbrochen – versuche Wiederverbindung …');
+ };
+ }
+
+ appendEntry(entry) {
+ this.entries.push(entry);
+ if (this.entries.length > this.maxLines) {
+ this.entries.shift();
+ }
+
+ const scroller = document.getElementById('log-scroller-el');
+ if (!scroller) return;
+
+ if (this.matchesFilter(entry)) {
+ const lineEl = document.createElement('div');
+ lineEl.className = `log-line ${entry.isError ? 'log-error' : (entry.isWarn ? 'log-warn' : 'log-info')}`;
+ lineEl.textContent = entry.formatted || `[${entry.timestamp}] [${entry.level}] [${entry.module}] ${entry.message}`;
+ scroller.appendChild(lineEl);
+
+ const autoscroll = document.getElementById('log-autoscroll-chk');
+ if (autoscroll && autoscroll.checked) {
+ scroller.scrollTop = scroller.scrollHeight;
+ }
+ }
+
+ this.updateCounter();
+ }
+
+ matchesFilter(entry) {
+ if (!this.filter) return true;
+ const text = (entry.formatted || `${entry.module} ${entry.level} ${entry.message}`).toLowerCase();
+ return text.includes(this.filter);
+ }
+
+ reapplyFilter() {
+ const scroller = document.getElementById('log-scroller-el');
+ if (!scroller) return;
+
+ scroller.innerHTML = '';
+ for (const entry of this.entries) {
+ if (this.matchesFilter(entry)) {
+ const lineEl = document.createElement('div');
+ lineEl.className = `log-line ${entry.isError ? 'log-error' : (entry.isWarn ? 'log-warn' : 'log-info')}`;
+ lineEl.textContent = entry.formatted || `[${entry.timestamp}] [${entry.level}] [${entry.module}] ${entry.message}`;
+ scroller.appendChild(lineEl);
+ }
+ }
+ scroller.scrollTop = scroller.scrollHeight;
+ }
+
+ updateCounter() {
+ const el = document.getElementById('log-counter');
+ if (el) el.textContent = `${this.entries.length} Zeilen`;
+ }
+
+ destroy() {
+ if (this.eventSource) {
+ this.eventSource.close();
+ this.eventSource = null;
+ }
+ }
+}
diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/orders.js b/src/IBKRTrader.Hosting/wwwroot/js/views/orders.js
new file mode 100644
index 0000000..7624740
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/js/views/orders.js
@@ -0,0 +1,205 @@
+// ─── Orders View ───
+
+import { api } from '../api.js';
+
+export class OrdersView {
+ constructor(container, setStatus) {
+ this.container = container;
+ this.setStatus = setStatus;
+ this.orders = [];
+ this.selected = null;
+ }
+
+ async render() {
+ this.container.innerHTML = `
+
+
+
+
+
+ ⚠️ GEFÄHRLICHER ZUSTAND:
+
+
+
+
+ Offene Orders beim Broker
+
+
+
+
+ | Order-ID |
+ Symbol |
+ Richtung |
+ Menge |
+ Gefüllt |
+ Offen |
+ Typ |
+ Limit |
+ Status |
+ Eigene Kapitalbindung |
+
+
+
+ | Lade Orders … |
+
+
+
+
+ Eigene Kapitalbindungen (Reservierungen)
+
+
+
+
+ | ID |
+ Buch |
+ Richtung |
+ Menge |
+ Kontrakt |
+ Betrag |
+ Zustand |
+ Broker Order-ID |
+
+
+
+ | Lade Bindungen … |
+
+
+
+
+ `;
+
+ document.getElementById('ord-refresh-btn').addEventListener('click', () => this.refresh());
+ document.getElementById('ord-cancel-btn').addEventListener('click', () => this.cancelSelected());
+ document.getElementById('ord-cancel-all-btn').addEventListener('click', () => this.cancelAll());
+
+ await this.refresh();
+ }
+
+ async refresh() {
+ this.setStatus('Lade Orderbuch und Bindungen …');
+ try {
+ const data = await api.get('/api/orders');
+ if (!data) return;
+
+ this.orders = data.orders || [];
+ const ordTbody = document.getElementById('ord-tbody');
+ const resTbody = document.getElementById('res-tbody');
+
+ // Orders
+ if (this.orders.length === 0) {
+ ordTbody.innerHTML = `| Keine offenen Orders beim Broker. |
`;
+ } else {
+ ordTbody.innerHTML = this.orders.map((o, idx) => `
+
+ | #${o.orderId} |
+ ${o.symbol} |
+ ${o.side === 'Buy' ? 'Kauf' : 'Verkauf'} |
+ ${Number(o.quantity).toLocaleString('de-DE')} |
+ ${Number(o.filled).toLocaleString('de-DE')} |
+ ${Number(o.remaining).toLocaleString('de-DE')} |
+ ${o.orderType} |
+ ${o.limitPrice !== null ? Number(o.limitPrice).toLocaleString('de-DE', { minimumFractionDigits: 2 }) : '–'} |
+ ${o.status} |
+ ${o.isBackedByReservation ? `✔ Gedeckt durch #${o.reservationId} (${o.book})` : 'UNGEDECKT (keine Bindung!)'} |
+
+ `).join('');
+
+ ordTbody.querySelectorAll('tr').forEach(row => {
+ row.addEventListener('click', () => {
+ const idx = parseInt(row.getAttribute('data-idx'), 10);
+ this.selectRow(idx);
+ });
+ });
+ }
+
+ // Reservations
+ const reservations = data.reservations || [];
+ if (reservations.length === 0) {
+ resTbody.innerHTML = `| Keine offenen Kapitalbindungen. |
`;
+ } else {
+ resTbody.innerHTML = reservations.map(r => `
+
+ | #${r.id} |
+ ${r.book} |
+ ${r.side} |
+ ${Number(r.quantity).toLocaleString('de-DE')} |
+ ${r.contractKey} |
+ ${Number(r.amount).toLocaleString('de-DE', { minimumFractionDigits: 2 })} ${r.currency} |
+ ${r.state} |
+ ${r.brokerOrderId ? `#${r.brokerOrderId}` : '(noch keine)'} |
+
+ `).join('');
+ }
+
+ // Unbacked Alert
+ const alertBox = document.getElementById('ord-unbacked-alert');
+ const alertMsg = document.getElementById('ord-unbacked-msg');
+ if (data.unbackedOrders && data.unbackedOrders.length > 0) {
+ alertBox.style.display = 'block';
+ alertMsg.textContent = `${data.unbackedOrders.length} Order(s) liegen beim Broker ohne eigene Kapitalbindung! Führt eine davon aus, entsteht eine ungedeckte Position.`;
+ } else {
+ alertBox.style.display = 'none';
+ }
+
+ const now = new Date().toLocaleTimeString('de-DE');
+ document.getElementById('ord-status-summary').textContent =
+ `Orders: ${this.orders.length}, Bindungen: ${reservations.length} | ${now}`;
+ this.setStatus(`Orders geladen | ${now}`);
+ } catch (err) {
+ this.setStatus(`Fehler beim Laden der Orders: ${err.message}`);
+ }
+ }
+
+ selectRow(idx) {
+ const tbody = document.getElementById('ord-tbody');
+ const cancelBtn = document.getElementById('ord-cancel-btn');
+
+ tbody.querySelectorAll('tr').forEach(r => r.classList.remove('selected'));
+
+ if (idx >= 0 && idx < this.orders.length) {
+ this.selected = this.orders[idx];
+ const selectedTr = tbody.querySelector(`tr[data-idx="${idx}"]`);
+ if (selectedTr) selectedTr.classList.add('selected');
+ cancelBtn.disabled = false;
+ this.setStatus(`Ausgewählt: Order #${this.selected.orderId} (${this.selected.symbol})`);
+ } else {
+ this.selected = null;
+ cancelBtn.disabled = true;
+ }
+ }
+
+ async cancelSelected() {
+ if (!this.selected) return;
+ const id = this.selected.orderId;
+ if (!confirm(`Soll Order #${id} (${this.selected.symbol}) wirklich storniert werden?`)) return;
+
+ this.setStatus(`Storniere Order #${id} …`);
+ try {
+ const res = await api.post(`/api/orders/${id}/cancel`);
+ this.setStatus(res.message || `Order #${id} storniert.`);
+ await this.refresh();
+ } catch (err) {
+ this.setStatus(`Fehler beim Stornieren von #${id}: ${err.message}`);
+ }
+ }
+
+ async cancelAll() {
+ if (!confirm('ACHTUNG: Möchtest du wirklich ALLE offenen Orders stornieren? (Nur im Paper-Modus zulässig!)')) return;
+
+ this.setStatus('Sammelstorno aller Orders läuft …');
+ try {
+ const res = await api.post('/api/orders/cancel-all');
+ this.setStatus(res.message || 'Sammelstorno ausgeführt.');
+ await this.refresh();
+ } catch (err) {
+ this.setStatus(`Sammelstorno fehlgeschlagen: ${err.message}`);
+ }
+ }
+}
diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/positions.js b/src/IBKRTrader.Hosting/wwwroot/js/views/positions.js
new file mode 100644
index 0000000..c1a2a03
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/js/views/positions.js
@@ -0,0 +1,256 @@
+// ─── Positions View ───
+
+import { api } from '../api.js';
+
+export class PositionsView {
+ constructor(container, setStatus) {
+ this.container = container;
+ this.setStatus = setStatus;
+ this.positions = [];
+ this.selected = null;
+ }
+
+ async render() {
+ this.container.innerHTML = `
+
+
+
+
+
+
+
+
+ | Buch |
+ Herkunft |
+ Instrument |
+ Menge |
+ Einstand |
+ Gebunden |
+ Broker |
+ Abweichung |
+ Sperren & Einschränkungen |
+
+
+
+ | Lade Positionen … |
+
+
+
+
+
⚠️ Bestände beim Broker ohne eigenes Buch (UNKNOWN TO US)
+
+
+
+ | Symbol |
+ Typ |
+ Währung |
+ Menge |
+ Einstand |
+ Hinweis |
+
+
+
+
+
+
+ `;
+
+ document.getElementById('pos-refresh-btn').addEventListener('click', () => this.refresh());
+ document.getElementById('pos-apply-lock-btn').addEventListener('click', () => this.applyLock());
+ document.getElementById('pos-reassign-btn').addEventListener('click', () => this.reassign());
+
+ await this.refresh();
+ }
+
+ async refresh() {
+ const chk = document.getElementById('pos-broker-chk');
+ const includeBroker = chk ? (chk.checked ? 1 : 0) : 1;
+
+ this.setStatus(includeBroker ? 'Lade Positionen und Broker-Bestand …' : 'Lade Positionen …');
+
+ try {
+ const data = await api.get(`/api/positions?broker=${includeBroker}`);
+ if (!data) return;
+
+ this.positions = data.positions || [];
+ const tbody = document.getElementById('pos-tbody');
+
+ if (this.positions.length === 0) {
+ tbody.innerHTML = `| Keine offenen Positionen. |
`;
+ } else {
+ tbody.innerHTML = this.positions.map((p, idx) => `
+
+ | ${p.book} |
+ ${p.origin} |
+ ${p.instrument} |
+ ${Number(p.quantity).toLocaleString('de-DE')} |
+ ${Number(p.avgPrice).toLocaleString('de-DE', { minimumFractionDigits: 2 })} |
+ ${Number(p.boundCapital).toLocaleString('de-DE', { maximumFractionDigits: 0 })} ${p.currency} |
+ ${p.brokerQuantity !== null ? Number(p.brokerQuantity).toLocaleString('de-DE') : '–'} |
+ ${p.diverges ? 'Abweichung' : '–'} |
+ ${this.formatRestrictions(p)} |
+
+ `).join('');
+
+ tbody.querySelectorAll('tr').forEach(row => {
+ row.addEventListener('click', () => {
+ const idx = parseInt(row.getAttribute('data-idx'), 10);
+ this.selectRow(idx);
+ });
+ });
+ }
+
+ // Unknown section
+ const unknownSec = document.getElementById('unknown-section');
+ const unknownTbody = document.getElementById('unknown-tbody');
+ if (unknownSec && unknownTbody) {
+ if (data.unknownToUs && data.unknownToUs.length > 0) {
+ unknownSec.style.display = 'block';
+ unknownTbody.innerHTML = data.unknownToUs.map(u => `
+
+ | ${u.symbol} |
+ ${u.secType} |
+ ${u.currency} |
+ ${Number(u.quantity).toLocaleString('de-DE')} |
+ ${Number(u.avgCost).toLocaleString('de-DE', { minimumFractionDigits: 2 })} |
+ Bestand ohne Buch. Beim Abgleich wird er nach UNASSIGNED gebucht. |
+
+ `).join('');
+ } else {
+ unknownSec.style.display = 'none';
+ }
+ }
+
+ const now = new Date().toLocaleTimeString('de-DE');
+ const diverging = this.positions.filter(p => p.diverges).length;
+ let summary = `${this.positions.length} Position(en)`;
+ if (diverging > 0) summary += `, ${diverging} weichen vom Broker ab`;
+ if (!data.brokerReachable && includeBroker) summary += ' (Broker nicht erreichbar)';
+ document.getElementById('pos-summary-text').textContent = `${summary} | ${now}`;
+ this.setStatus(`Positionen geladen | ${now}`);
+ } catch (err) {
+ this.setStatus(`Fehler beim Laden der Positionen: ${err.message}`);
+ }
+ }
+
+ selectRow(idx) {
+ const tbody = document.getElementById('pos-tbody');
+ const lockBtn = document.getElementById('pos-apply-lock-btn');
+ const reassignBtn = document.getElementById('pos-reassign-btn');
+
+ tbody.querySelectorAll('tr').forEach(r => r.classList.remove('selected'));
+
+ if (idx >= 0 && idx < this.positions.length) {
+ this.selected = this.positions[idx];
+ const selectedTr = tbody.querySelector(`tr[data-idx="${idx}"]`);
+ if (selectedTr) selectedTr.classList.add('selected');
+ lockBtn.disabled = false;
+ reassignBtn.disabled = false;
+ this.setStatus(`Ausgewählt: ${this.selected.instrument} (${this.selected.book})`);
+ } else {
+ this.selected = null;
+ lockBtn.disabled = true;
+ reassignBtn.disabled = true;
+ }
+ }
+
+ formatRestrictions(p) {
+ const parts = [];
+ if (p.positionLock && p.positionLock !== 'Free') {
+ parts.push(`${p.positionLock}`);
+ }
+ if (p.blocked) {
+ parts.push(`Gesperrt`);
+ }
+ if (p.maxQuantity !== null && p.maxQuantity !== undefined) {
+ parts.push(`max. ${p.maxQuantity} Stk.`);
+ }
+ return parts.length > 0 ? parts.join(' ') : '–';
+ }
+
+ async applyLock() {
+ if (!this.selected) return;
+ const level = document.getElementById('pos-lock-select').value;
+ this.setStatus(`Setze Sperre ${level} für ${this.selected.instrument} …`);
+
+ try {
+ const res = await api.post('/api/positions/lock', {
+ instrument: this.selected.instrument,
+ book: this.selected.book,
+ level: level,
+ note: 'Über Web-UI gesetzt'
+ });
+ this.setStatus(res.message || 'Sperre erfolgreich gesetzt.');
+ await this.refresh();
+ } catch (err) {
+ this.setStatus(`Fehler beim Setzen der Sperre: ${err.message}`);
+ }
+ }
+
+ async reassign() {
+ if (!this.selected) return;
+ const targetBook = document.getElementById('pos-target-book').value;
+ const reasonInput = document.getElementById('pos-reassign-reason');
+ const reason = reasonInput ? reasonInput.value.trim() : '';
+
+ if (!reason) {
+ alert('Bitte eine Begründung für die Umbuchung eingeben.');
+ return;
+ }
+
+ this.setStatus(`Bucht ${this.selected.instrument} von ${this.selected.book} nach ${targetBook} um …`);
+
+ try {
+ const res = await api.post('/api/positions/reassign', {
+ instrument: this.selected.instrument,
+ fromBook: this.selected.book,
+ toBook: targetBook,
+ reason: reason
+ });
+
+ if (res.success) {
+ if (reasonInput) reasonInput.value = '';
+ this.setStatus(res.message || 'Erfolgreich umgebucht.');
+ await this.refresh();
+ } else {
+ this.setStatus(`Umbuchung abgelehnt: ${res.message}`);
+ }
+ } catch (err) {
+ this.setStatus(`Fehler: ${err.message}`);
+ }
+ }
+}
diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/settings.js b/src/IBKRTrader.Hosting/wwwroot/js/views/settings.js
new file mode 100644
index 0000000..d83a5ed
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/js/views/settings.js
@@ -0,0 +1,149 @@
+// ─── Settings View ───
+
+import { api } from '../api.js';
+
+export class SettingsView {
+ constructor(container, setStatus) {
+ this.container = container;
+ this.setStatus = setStatus;
+ this.sections = [];
+ this.changedValues = {};
+ }
+
+ async render() {
+ this.container.innerHTML = `
+
+
+
+ `;
+
+ document.getElementById('set-save-btn').addEventListener('click', () => this.save());
+ document.getElementById('set-reload-btn').addEventListener('click', () => this.load());
+
+ await this.load();
+ }
+
+ async load() {
+ this.setStatus('Lade Einstellungen …');
+ this.changedValues = {};
+
+ try {
+ const data = await api.get('/api/settings');
+ if (!Array.isArray(data)) return;
+
+ this.sections = data;
+ const formContainer = document.getElementById('settings-form-container');
+ if (!formContainer) return;
+
+ formContainer.innerHTML = data.map((section, sIdx) => `
+
+
+
+ ${section.fields.map(f => this.renderField(f)).join('')}
+
+
+ `).join('');
+
+ // Wire input changes
+ data.forEach(sec => {
+ sec.fields.forEach(f => {
+ const inputEl = document.getElementById(`field-${this.escapeId(f.path)}`);
+ if (inputEl) {
+ inputEl.addEventListener('change', () => {
+ if (f.typeName === 'bool') {
+ this.changedValues[f.path] = inputEl.checked;
+ } else if (f.typeName === 'int' || f.typeName === 'decimal') {
+ this.changedValues[f.path] = Number(inputEl.value);
+ } else {
+ this.changedValues[f.path] = inputEl.value;
+ }
+ document.getElementById('set-status-msg').textContent = `${Object.keys(this.changedValues).length} Änderung(en) ungespeichert`;
+ });
+ }
+ });
+ });
+
+ this.setStatus('Einstellungen geladen.');
+ document.getElementById('set-status-msg').textContent = 'Bereit.';
+ } catch (err) {
+ this.setStatus(`Fehler beim Laden: ${err.message}`);
+ }
+ }
+
+ escapeId(path) {
+ return path.replace(/[^a-zA-Z0-9_-]/g, '_');
+ }
+
+ renderField(f) {
+ const id = `field-${this.escapeId(f.path)}`;
+ let controlHtml = '';
+
+ if (f.typeName === 'bool') {
+ controlHtml = ``;
+ } else if (f.typeName === 'enum' && f.enumValues) {
+ controlHtml = `
+
+ `;
+ } else if (f.isPassword) {
+ controlHtml = `
+
+ `;
+ } else if (f.typeName === 'int' || f.typeName === 'decimal') {
+ controlHtml = `
+
+ `;
+ } else {
+ controlHtml = `
+
+ `;
+ }
+
+ return `
+
+
${f.displayName}
+ ${f.description ? `
${f.description}
` : ''}
+
+ ${controlHtml}
+ `;
+ }
+
+ escapeHtml(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+ }
+
+ async save() {
+ const count = Object.keys(this.changedValues).length;
+ if (count === 0) {
+ this.setStatus('Keine Änderungen vorhanden.');
+ return;
+ }
+
+ this.setStatus(`Speichere ${count} Einstellung(en) …`);
+
+ try {
+ const res = await api.post('/api/settings', { updates: this.changedValues });
+ this.setStatus(res.message || 'Einstellungen erfolgreich gespeichert.');
+ document.getElementById('set-status-msg').textContent = 'Gespeichert.';
+ await this.load();
+ } catch (err) {
+ this.setStatus(`Speichern fehlgeschlagen: ${err.message}`);
+ }
+ }
+}
diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/supervisor.js b/src/IBKRTrader.Hosting/wwwroot/js/views/supervisor.js
new file mode 100644
index 0000000..2a8b418
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/js/views/supervisor.js
@@ -0,0 +1,304 @@
+// ─── Supervisor View ───
+
+import { api } from '../api.js';
+
+export class SupervisorView {
+ constructor(container, setStatus) {
+ this.container = container;
+ this.setStatus = setStatus;
+ this.currentSubTab = 'analysis';
+ this.profiles = [];
+ this.signals = [];
+ this.selectedSignal = null;
+ }
+
+ async render() {
+ this.container.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const subtabs = this.container.querySelectorAll('[data-subtab]');
+ subtabs.forEach(tab => {
+ tab.addEventListener('click', () => {
+ subtabs.forEach(t => t.classList.remove('active'));
+ tab.classList.add('active');
+ this.currentSubTab = tab.getAttribute('data-subtab');
+ this.renderSubTab();
+ });
+ });
+
+ await this.loadProfiles();
+ await this.renderSubTab();
+ }
+
+ async loadProfiles() {
+ try {
+ const data = await api.get('/api/modules/supervisor/profiles');
+ if (Array.isArray(data)) {
+ this.profiles = data;
+ }
+ } catch (e) {
+ console.warn('Konnte Supervisor-Profile nicht laden:', e);
+ }
+ }
+
+ async renderSubTab() {
+ const sub = document.getElementById('sup-subcontent');
+ if (!sub) return;
+
+ if (this.currentSubTab === 'analysis') {
+ sub.innerHTML = `
+
+
+
+
+
+
+
+ Wählen Sie ein Profil, geben Sie eine Frage ein und klicken Sie auf 'Fragen'.
+
+ `;
+
+ document.getElementById('sup-ask-btn').addEventListener('click', () => this.ask());
+ document.getElementById('sup-question-input').addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.ask();
+ }
+ });
+ } else if (this.currentSubTab === 'dossier') {
+ sub.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+ | Zeit |
+ Symbol |
+ Modul |
+ Entscheidung |
+
+
+
+ | Signale laden … |
+
+
+
+
+
+
+
+
+
+
+
+ Signal in der linken Tabelle auswählen.
+
+
+
+ `;
+
+ document.getElementById('sup-load-signals-btn').addEventListener('click', () => this.loadSignals());
+ await this.loadSignals();
+ } else if (this.currentSubTab === 'reports') {
+ sub.innerHTML = `
+
+
+
+
+
+ | Erstellt |
+ Profil |
+ Modell |
+ Frage |
+ Tools |
+ Tokens |
+
+
+
+ | Lade Berichte … |
+
+
+
+ `;
+
+ document.getElementById('sup-load-reports-btn').addEventListener('click', () => this.loadReports());
+ await this.loadReports();
+ } else if (this.currentSubTab === 'info') {
+ sub.innerHTML = `
+
+
Supervisor Modul-Hinweise
+
Lade Modul-Informationen …
+
+ `;
+ await this.loadInfo();
+ }
+ }
+
+ async ask() {
+ const qEl = document.getElementById('sup-question-input');
+ const question = qEl?.value?.trim();
+ if (!question) {
+ alert('Bitte eine Frage eingeben.');
+ return;
+ }
+
+ const pEl = document.getElementById('sup-profile-select');
+ const profile = pEl?.value;
+ const btn = document.getElementById('sup-ask-btn');
+ const ansEl = document.getElementById('sup-answer-container');
+ const statusEl = document.getElementById('sup-ask-status');
+
+ btn.disabled = true;
+ btn.textContent = '⏳ Analysiere …';
+ if (statusEl) statusEl.textContent = 'Agent denkt nach …';
+ ansEl.textContent = 'Agent führt Tool-Aufrufe und Analysen durch … Bitte warten.';
+
+ try {
+ const res = await api.post('/api/modules/supervisor/ask', { question, profile });
+ if (res && res.success) {
+ let output = res.answer || '(Keine Antwort erhalten)';
+ if (res.toolCalls && res.toolCalls.length > 0) {
+ output += '\n\n── Ausgeführte Tool-Calls ──\n' +
+ res.toolCalls.map(tc => `• ${tc.tool}: ${JSON.stringify(tc.arguments)}`).join('\n');
+ }
+ ansEl.textContent = output;
+ if (statusEl) statusEl.textContent = `Fertig (${res.toolCallCount || 0} Tools genutzt).`;
+ } else {
+ ansEl.textContent = `Fehler: ${res?.message || 'Unbekannter Fehler'}`;
+ }
+ } catch (e) {
+ ansEl.textContent = `Fehler bei der Abfrage: ${e.message}`;
+ } finally {
+ btn.disabled = false;
+ btn.textContent = '⚡ Fragen';
+ }
+ }
+
+ async loadSignals() {
+ this.setStatus('Lade Signale …');
+ try {
+ const signals = await api.get('/api/modules/supervisor/signals?limit=100');
+ this.signals = Array.isArray(signals) ? signals : [];
+ const tbody = document.getElementById('sup-signals-tbody');
+ if (!tbody) return;
+
+ if (this.signals.length === 0) {
+ tbody.innerHTML = `| Keine Signale gefunden. |
`;
+ } else {
+ tbody.innerHTML = this.signals.map((s, idx) => `
+
+ | ${s.firstSeen ? s.firstSeen.substring(5, 16) : '–'} |
+ ${s.symbol || '–'} |
+ ${s.module || '–'} |
+ ${s.lastDecision || '–'} |
+
+ `).join('');
+
+ tbody.querySelectorAll('tr').forEach(tr => {
+ tr.addEventListener('click', () => {
+ const idx = parseInt(tr.getAttribute('data-idx'), 10);
+ this.selectSignal(this.signals[idx]);
+ });
+ });
+ }
+
+ this.setStatus(`Signale geladen (${this.signals.length}).`);
+ } catch (e) {
+ this.setStatus(`Fehler: ${e.message}`);
+ }
+ }
+
+ async selectSignal(s) {
+ this.selectedSignal = s;
+ const headerEl = document.getElementById('sup-dossier-header');
+ const bodyEl = document.getElementById('sup-dossier-body');
+ if (!bodyEl) return;
+
+ if (headerEl) headerEl.textContent = `Dossier: ${s.symbol} (${s.signalId})`;
+ bodyEl.textContent = 'Lade Dossier …';
+
+ try {
+ const data = await api.get(`/api/modules/supervisor/dossier/${encodeURIComponent(s.signalId)}`);
+ if (data && data.markdown) {
+ bodyEl.textContent = data.markdown;
+ } else {
+ bodyEl.textContent = 'Kein Dossier für dieses Signal verfügbar.';
+ }
+ } catch (e) {
+ bodyEl.textContent = `Fehler beim Laden des Dossiers: ${e.message}`;
+ }
+ }
+
+ async loadReports() {
+ this.setStatus('Lade Berichte …');
+ try {
+ const reports = await api.get('/api/modules/supervisor/reports?limit=100');
+ const tbody = document.getElementById('sup-reports-tbody');
+ if (!tbody) return;
+
+ if (!Array.isArray(reports) || reports.length === 0) {
+ tbody.innerHTML = `| Keine Berichte vorhanden. |
`;
+ } else {
+ tbody.innerHTML = reports.map(r => `
+
+ | ${r.createdAt} |
+ ${r.profile} |
+ ${r.model} |
+ ${r.question} |
+ ${r.toolCallCount} |
+ ${r.tokens} |
+
+ `).join('');
+ }
+ this.setStatus(`Berichte geladen (${reports ? reports.length : 0}).`);
+ } catch (e) {
+ this.setStatus(`Fehler: ${e.message}`);
+ }
+ }
+
+ async loadInfo() {
+ try {
+ const info = await api.get('/api/modules/supervisor/info');
+ const el = document.getElementById('sup-info-content');
+ if (!el || !info) return;
+
+ el.innerHTML = `
+
+ OpenRouter API-Key: ${info.openRouterKeyConfigured ? '✅ Konfiguriert' : '⚠️ Nicht gesetzt (in settings.json oder OPENROUTER_API_KEY hinterlegen)'}
+
+ Zeitzone: ${info.timeZone || '–'}
+ Beschreibung: ${info.description || '–'}
+ `;
+ } catch (e) {
+ console.error(e);
+ }
+ }
+}
diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/workers.js b/src/IBKRTrader.Hosting/wwwroot/js/views/workers.js
new file mode 100644
index 0000000..fd1a23e
--- /dev/null
+++ b/src/IBKRTrader.Hosting/wwwroot/js/views/workers.js
@@ -0,0 +1,125 @@
+// ─── Workers / Services View ───
+
+import { api } from '../api.js';
+
+export class WorkersView {
+ constructor(container, setStatus) {
+ this.container = container;
+ this.setStatus = setStatus;
+ this.selectedWorker = null;
+ this.workers = [];
+ }
+
+ async render() {
+ this.container.innerHTML = `
+
+
+
+
+
+
+ | Aktiv |
+ Typ |
+ Modul |
+ Worker |
+ Letzter Lauf |
+ Nächster Lauf |
+ Intervall |
+ Info |
+
+
+
+ | Lade Worker … |
+
+
+
+ `;
+
+ document.getElementById('workers-refresh-btn').addEventListener('click', () => this.refresh());
+ document.getElementById('workers-trigger-btn').addEventListener('click', () => this.triggerSelected());
+
+ await this.refresh();
+ }
+
+ async refresh() {
+ this.setStatus('Lade Worker-Liste …');
+ try {
+ const data = await api.get('/api/workers');
+ if (!Array.isArray(data)) return;
+
+ this.workers = data;
+ const tbody = document.getElementById('workers-tbody');
+ const triggerBtn = document.getElementById('workers-trigger-btn');
+
+ if (data.length === 0) {
+ tbody.innerHTML = `| Keine Worker registriert. |
`;
+ return;
+ }
+
+ tbody.innerHTML = data.map((w, idx) => `
+
+ | ${w.active ? '✔' : '–'} |
+ ${w.type} |
+ ${w.module} |
+ ${w.workerName} |
+ ${w.lastRuntime || '–'} |
+ ${w.nextRuntime || '–'} |
+ ${w.runEvery || '–'} |
+ ${w.info || '–'} |
+
+ `).join('');
+
+ // Wire row click
+ tbody.querySelectorAll('tr').forEach(row => {
+ row.addEventListener('click', () => {
+ const idx = parseInt(row.getAttribute('data-idx'), 10);
+ this.selectRow(idx);
+ });
+ });
+
+ const now = new Date().toLocaleTimeString('de-DE');
+ document.getElementById('workers-status-text').textContent = `${data.length} Worker/Services registriert | ${now}`;
+ this.setStatus(`Worker-Liste aktualisiert | ${now}`);
+ } catch (err) {
+ this.setStatus(`Fehler: ${err.message}`);
+ }
+ }
+
+ selectRow(idx) {
+ const tbody = document.getElementById('workers-tbody');
+ const triggerBtn = document.getElementById('workers-trigger-btn');
+
+ tbody.querySelectorAll('tr').forEach(r => r.classList.remove('selected'));
+
+ if (idx >= 0 && idx < this.workers.length) {
+ this.selectedWorker = this.workers[idx];
+ const selectedTr = tbody.querySelector(`tr[data-idx="${idx}"]`);
+ if (selectedTr) selectedTr.classList.add('selected');
+ triggerBtn.disabled = false;
+ this.setStatus(`Ausgewählt: ${this.selectedWorker.workerName}`);
+ } else {
+ this.selectedWorker = null;
+ triggerBtn.disabled = true;
+ }
+ }
+
+ async triggerSelected() {
+ if (!this.selectedWorker) return;
+ const name = this.selectedWorker.workerName;
+ this.setStatus(`${name} wird ausgelöst …`);
+
+ try {
+ const res = await api.post(`/api/workers/${encodeURIComponent(name)}/trigger`);
+ this.setStatus(res.message || `${name} erfolgreich ausgelöst.`);
+ await this.refresh();
+ } catch (err) {
+ this.setStatus(`Fehler beim Auslösen von ${name}: ${err.message}`);
+ }
+ }
+}
diff --git a/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj b/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj
index 2a87987..14fa1b9 100644
--- a/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj
+++ b/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj
@@ -28,6 +28,7 @@
+