feat: Anthropic API-Key über Admin-UI konfigurierbar

- GET /settings/ai-config: gibt { configured: boolean } zurück
- POST /settings/ai-config: speichert Key Base64-kodiert in Redis (PLATFORM_ADMIN)
- HelpService: dynamische Key-Auflösung aus Redis mit 60s In-Memory-Cache
- AdminAiSettingsPage: neue Admin-Seite /admin/ai-settings
- HelpPanel: zeigt Hinweis + Link wenn KI nicht konfiguriert
- Env-Variable ANTHROPIC_API_KEY hat weiterhin Vorrang (backward compat.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-15 10:59:30 +01:00
parent c96ccb5fcc
commit 0c8a23ddc4
8 changed files with 495 additions and 87 deletions

View file

@ -1,8 +1,88 @@
# INSIGHT MVP - Aenderungsprotokoll # INSIGHT MVP - Aenderungsprotokoll
## Stand: 2026-03-13 ## Stand: 2026-03-15
### Aktueller Sprint: CRM Phase 3 — Kanban-Board + Microsoft 365 OAuth-Integration (Feature-Branch: feature/crm-service) ### Aktueller Sprint: CRM Phase 3 + Stammdaten + Reporting + Hilfesystem + KI-Einstellungen (Feature-Branch: feature/crm-service)
---
### Aenderungen 2026-03-15 (20): KI-Einstellungen über Admin-UI konfigurierbar
#### Feature: Anthropic API-Key über Admin-UI setzen
**Problem**: `ANTHROPIC_API_KEY` war nur per `.env`-Datei setzbar — Admins ohne Server-Zugang konnten den KI-Chat nicht aktivieren.
**Backend (Core Service)**
- `src/core/settings/settings.controller.ts` — 2 neue Endpoints:
- `GET /settings/ai-config` — gibt `{ configured: boolean }` zurück (Key wird niemals zurückgesendet)
- `POST /settings/ai-config` — speichert Key Base64-kodiert in Redis unter `platform_ai_config`; leerer String löscht den Key (@Roles PLATFORM_ADMIN)
- `src/core/help/help.service.ts` — RedisService injiziert; Key wird per `getApiKey()` dynamisch geladen (Env-Variable hat Vorrang, 60s In-Memory-Cache); Cache-Invalidierung bei 401-Fehler
- `src/core/help/help.module.ts` — RedisModule importiert
**Frontend**
- `src/admin/AdminAiSettingsPage.tsx` (neu) — Admin-Seite mit Status-Anzeige, Password-Input, Speichern/Entfernen
- `src/admin/AdminLayout.tsx` — Tab "KI-Einstellungen" ergänzt
- `src/shell/App.tsx` — Route `/admin/ai-settings` + Import ergänzt
- `src/components/HelpPanel/HelpPanel.tsx``useQuery(['settings', 'ai-config'])`: wenn nicht konfiguriert → Hinweis mit Link zu Admin-Seite statt Chat-Input
**Sicherheit**: GET gibt niemals den Key zurück. Base64-Kodierung (konsistent mit Projektstil). Backward-Kompatibilität: `ANTHROPIC_API_KEY` in `.env` hat weiterhin Vorrang.
---
### Aenderungen 2026-03-15 (19): Stammdaten, CRM Reporting, Hilfesystem
#### Feature 1: Stammdaten (Master Data)
**Backend (Core Service)**
- `prisma/core.schema.prisma` — 5 neue Modelle: `Department`, `Location`, `CostCenter`, `JobTitle`, `SkillCategory` (Tabellen: departments, locations, cost_centers, job_titles, skill_categories)
- `src/core/master-data/master-data.service.ts` — CRUD-Service für alle 5 Stammdaten-Entitäten
- `src/core/master-data/master-data.controller.ts` — REST-Controller mit Public-Endpunkten (Dropdowns) + Admin-Endpunkten (@Roles('PLATFORM_ADMIN'))
- `src/core/master-data/master-data.module.ts` — NestJS-Modul
- `src/app.module.ts` — MasterDataModule registriert
**Frontend**
- `src/admin/AdminMasterDataPage.tsx` — Admin-Seite mit 5 Tabs, inline Edit/Delete/Create
- `src/admin/AdminLayout.tsx` — Tab "Stammdaten" ergänzt
- `src/shell/App.tsx` — Route `/admin/master-data` ergänzt
**Deployment-Hinweis**: `prisma migrate deploy` auf Server erforderlich + Rebuild core-service + frontend
---
#### Feature 2: CRM Reporting
**Backend (CRM Service)**
- `src/deals/deals.service.ts` — Methode `getStats()`: Win/Loss-Rate, Umsatz, Avg-Deal-Value, LostByReason, MonthlyTrend (12 Monate)
- `src/deals/dto/stats-query.dto.ts``StatsQueryDto` mit Period-Enum (MONTH/QUARTER/YEAR)
- `src/deals/deals.controller.ts` — GET `/deals/stats` Endpunkt
- `src/activities/activities.service.ts` — Methode `getStats()`: Aktivitäten nach Typ, Erledigungsrate, Upcoming Tasks
- `src/activities/activities.controller.ts` — GET `/activities/stats` Endpunkt
**Frontend**
- `package.json``recharts ^2.12.0` als Dependency
- `src/crm/types.ts` — Interfaces `DealStats`, `ActivityStats`, `MonthlyTrendEntry`, `LostByReasonEntry`, `ActivityStatsByType`
- `src/crm/api.ts``dealStatsApi.get()`, `activitiesApi.getStats()` ergänzt
- `src/crm/hooks.ts``useDealStats()`, `useActivityStats()` Hooks
- `src/crm/reports/ReportsPage.tsx` — Reporting-Seite mit Period-Selector, Deal/Aktivitäts-Tabs, LineChart/BarChart/PieChart (recharts)
- `src/shell/App.tsx` — Route `/crm/reports`
- `src/shell/AppLayout.tsx` — Sidebar-Link "Reports" nach Kanban
---
#### Feature 3: Hilfesystem (KI-Chat + Tooltips + Panel)
**Backend (Core Service)**
- `package.json``@anthropic-ai/sdk ^0.37.0`
- `src/config/env.validation.ts``ANTHROPIC_API_KEY` (optional)
- `src/core/help/help.service.ts` — Claude Haiku Integration, Chat-Methode mit System-Prompt
- `src/core/help/help.controller.ts` — POST `/help/chat`
- `src/core/help/help.module.ts` — NestJS-Modul
- `src/app.module.ts` — HelpModule registriert
**Frontend**
- `src/components/HelpTooltip/HelpTooltip.tsx` + `index.ts` — Hover-Tooltip mit ❓-Icon
- `src/components/HelpPanel/HelpPanel.tsx` + `index.ts` — Side-Panel mit Kontexthilfe + KI-Chat
- `src/shell/AppLayout.tsx` — ❓-Button in Topbar + HelpPanel-Integration
--- ---

View file

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { HelpController } from './help.controller'; import { HelpController } from './help.controller';
import { HelpService } from './help.service'; import { HelpService } from './help.service';
import { RedisModule } from '../../redis/redis.module';
@Module({ @Module({
imports: [RedisModule],
controllers: [HelpController], controllers: [HelpController],
providers: [HelpService], providers: [HelpService],
}) })

View file

@ -1,42 +1,76 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk'; import Anthropic from '@anthropic-ai/sdk';
import { RedisService } from '../../redis/redis.service';
export interface ChatMessage { export interface ChatMessage {
role: 'user' | 'assistant'; role: 'user' | 'assistant';
content: string; content: string;
} }
const AI_CONFIG_KEY = 'platform_ai_config';
@Injectable() @Injectable()
export class HelpService { export class HelpService {
private readonly logger = new Logger(HelpService.name); private readonly logger = new Logger(HelpService.name);
private client: Anthropic | null = null;
constructor(private readonly config: ConfigService) { // In-Memory-Cache für den API-Key (60s TTL) — vermeidet Redis-Overhead pro Request
const apiKey = this.config.get<string>('ANTHROPIC_API_KEY'); private cachedKey: string | null = null;
if (apiKey) { private cacheExpiry = 0;
this.client = new Anthropic({ apiKey });
this.logger.log('KI-Hilfe-Chat aktiviert'); constructor(
} else { private readonly config: ConfigService,
this.logger.warn('ANTHROPIC_API_KEY nicht konfiguriert — KI-Chat deaktiviert'); private readonly redis: RedisService,
) {}
/**
* Liest den Anthropic API-Key.
* Priorität: Env-Variable (backward compat.) Redis (Admin-UI-Konfiguration).
* 60s In-Memory-Cache.
*/
private async getApiKey(): Promise<string | null> {
// 1. Env-Variable hat Vorrang
const envKey = this.config.get<string>('ANTHROPIC_API_KEY');
if (envKey) return envKey;
// 2. Redis-Cache prüfen
if (this.cachedKey !== null && Date.now() < this.cacheExpiry) {
return this.cachedKey;
} }
// 3. Aus Redis laden (Base64-dekodieren)
const raw = await this.redis.get(AI_CONFIG_KEY);
this.cachedKey = raw ? Buffer.from(raw, 'base64').toString('utf-8') : null;
this.cacheExpiry = Date.now() + 60_000;
if (this.cachedKey) {
this.logger.debug('Anthropic API-Key aus Redis geladen');
}
return this.cachedKey;
} }
async chat(messages: ChatMessage[], context?: string): Promise<{ reply: string }> { async chat(messages: ChatMessage[], context?: string): Promise<{ reply: string }> {
if (!this.client) { const apiKey = await this.getApiKey();
throw new ServiceUnavailableException('KI-Chat nicht konfiguriert. Bitte ANTHROPIC_API_KEY setzen.');
if (!apiKey) {
throw new ServiceUnavailableException(
'KI-Chat nicht konfiguriert. Bitte einen Anthropic API-Key unter Admin > KI-Einstellungen hinterlegen.',
);
} }
const client = new Anthropic({ apiKey });
const systemPrompt = [ const systemPrompt = [
'Du bist ein freundlicher Hilfsassistent für INSIGHT, eine Business-Plattform für Mittelstandsunternehmen.', 'Du bist ein freundlicher Hilfsassistent für INSIGHT, eine Business-Plattform für Mittelstandsunternehmen.',
'INSIGHT enthält folgende Bereiche: CRM (Unternehmen, Kontakte, Deals, Aktivitäten, Kanban, Reports), Expertenprofil (Skills, Projekte, Zertifizierungen), Dashboard (E-Mail, Kalender, Aufgaben via Microsoft 365), Admin-Bereich (Benutzerverwaltung, Branding, Firmendaten, Stammdaten).', 'INSIGHT enthält folgende Bereiche: CRM (Unternehmen, Kontakte, Deals, Aktivitäten, Kanban, Reports), Expertenprofil (Skills, Projekte, Zertifizierungen), Dashboard (E-Mail, Kalender, Aufgaben via Microsoft 365), Admin-Bereich (Benutzerverwaltung, Branding, Firmendaten, Stammdaten, KI-Einstellungen).',
'Beantworte Fragen zur Bedienung der Plattform kompakt und hilfsbereit auf Deutsch.', 'Beantworte Fragen zur Bedienung der Plattform kompakt und hilfsbereit auf Deutsch.',
'Halte Antworten kurz (max. 3-4 Sätze). Wenn du etwas nicht weißt, sag das ehrlich.', 'Halte Antworten kurz (max. 3-4 Sätze). Wenn du etwas nicht weißt, sag das ehrlich.',
context ? `Aktueller Kontext: ${context}` : '', context ? `Aktueller Kontext: ${context}` : '',
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
try { try {
const response = await this.client.messages.create({ const response = await client.messages.create({
model: 'claude-haiku-4-5', model: 'claude-haiku-4-5',
max_tokens: 1024, max_tokens: 1024,
system: systemPrompt, system: systemPrompt,
@ -50,6 +84,11 @@ export class HelpService {
return { reply }; return { reply };
} catch (err) { } catch (err) {
// Bei ungültigem API-Key Cache invalidieren
if ((err as { status?: number }).status === 401) {
this.cachedKey = null;
this.cacheExpiry = 0;
}
this.logger.error('Claude API Fehler:', err); this.logger.error('Claude API Fehler:', err);
throw new ServiceUnavailableException('KI-Chat vorübergehend nicht verfügbar.'); throw new ServiceUnavailableException('KI-Chat vorübergehend nicht verfügbar.');
} }

View file

@ -35,6 +35,7 @@ const EXTERNAL_LINKS_KEY = 'platform_external_links';
const BRANDING_LOGO_KEY = 'platform_branding_logo'; const BRANDING_LOGO_KEY = 'platform_branding_logo';
const SSL_CONFIG_KEY = 'platform_ssl_config'; const SSL_CONFIG_KEY = 'platform_ssl_config';
const COMPANY_SETTINGS_KEY = 'platform_company_settings'; const COMPANY_SETTINGS_KEY = 'platform_company_settings';
const AI_CONFIG_KEY = 'platform_ai_config';
interface CompanySettings { interface CompanySettings {
name: string | null; name: string | null;
@ -482,6 +483,45 @@ export class SettingsController {
return { success: true }; return { success: true };
} }
// ============================================================
// KI-Konfiguration (Anthropic API-Key)
// ============================================================
/**
* GET /api/v1/settings/ai-config
* Prüft ob ein Anthropic API-Key konfiguriert ist (gibt den Key NICHT zurück).
*/
@Get('ai-config')
@ApiOperation({ summary: 'KI-Konfiguration: Status prüfen' })
async getAiConfig(): Promise<{ configured: boolean }> {
const raw = await this.redis.get(AI_CONFIG_KEY);
return { configured: !!raw };
}
/**
* POST /api/v1/settings/ai-config
* Anthropic API-Key speichern oder entfernen (nur PLATFORM_ADMIN).
* Leerer apiKey = Key entfernen (KI-Chat deaktivieren).
*/
@Post('ai-config')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Anthropic API-Key speichern (Admin)' })
async saveAiConfig(
@Body() body: { apiKey: string },
): Promise<{ success: boolean }> {
const key = body.apiKey?.trim();
if (key) {
// Base64-kodiert speichern — kein Plaintext im Redis-Dump
await this.redis.set(AI_CONFIG_KEY, Buffer.from(key).toString('base64'));
this.logger.log('Anthropic API-Key gespeichert');
} else {
await this.redis.del(AI_CONFIG_KEY);
this.logger.log('Anthropic API-Key entfernt');
}
return { success: true };
}
// ============================================================ // ============================================================
// SSL / Domain Configuration // SSL / Domain Configuration
// ============================================================ // ============================================================

View file

@ -0,0 +1,198 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
const cardStyle: React.CSSProperties = {
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-sm)',
border: '1px solid var(--color-border)',
padding: '1.5rem',
marginBottom: '1.5rem',
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.75rem',
fontWeight: 600,
color: 'var(--color-text-secondary)',
marginBottom: '0.25rem',
textTransform: 'uppercase',
letterSpacing: '0.04em',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.5rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
background: 'var(--color-bg)',
color: 'var(--color-text)',
boxSizing: 'border-box',
fontFamily: 'monospace',
};
export function AdminAiSettingsPage() {
const qc = useQueryClient();
const [apiKey, setApiKey] = useState('');
const [saved, setSaved] = useState(false);
const { data, isLoading } = useQuery<{ configured: boolean }>({
queryKey: ['settings', 'ai-config'],
queryFn: () => api.get<{ configured: boolean }>('/settings/ai-config').then(r => r.data),
});
const saveMutation = useMutation({
mutationFn: (key: string) => api.post('/settings/ai-config', { apiKey: key }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['settings', 'ai-config'] });
setApiKey('');
setSaved(true);
setTimeout(() => setSaved(false), 3000);
},
});
const removeMutation = useMutation({
mutationFn: () => api.post('/settings/ai-config', { apiKey: '' }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['settings', 'ai-config'] });
},
});
const configured = data?.configured ?? false;
return (
<div style={{ maxWidth: 580 }}>
<div style={cardStyle}>
<h2 style={{ margin: '0 0 0.25rem', fontSize: '1.125rem', fontWeight: 700 }}>
KI-Einstellungen
</h2>
<p style={{ margin: '0 0 1.5rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
Konfigurieren Sie den Anthropic API-Key für den KI-Hilfe-Chat ( im Topbar).
</p>
{/* Status */}
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
marginBottom: '1.5rem',
padding: '0.75rem 1rem',
background: configured ? 'rgba(22,163,74,0.08)' : 'rgba(220,38,38,0.08)',
borderRadius: 'var(--radius-sm)',
border: `1px solid ${configured ? 'rgba(22,163,74,0.2)' : 'rgba(220,38,38,0.2)'}`,
}}>
<span style={{ fontSize: '0.875rem' }}>
{configured ? '🟢' : '🔴'}
</span>
<span style={{
fontSize: '0.875rem', fontWeight: 600,
color: configured ? 'var(--color-success, #16a34a)' : 'var(--color-danger, #dc2626)',
}}>
{isLoading ? 'Prüfe...' : configured ? 'API-Key konfiguriert' : 'Kein API-Key hinterlegt'}
</span>
{configured && (
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginLeft: 'auto' }}>
KI-Chat aktiv
</span>
)}
</div>
{/* Neuen Key setzen */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>
{configured ? 'Key ersetzen' : 'API-Key hinterlegen'}
</label>
<input
style={inputStyle}
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
placeholder="sk-ant-api03-..."
autoComplete="off"
spellCheck={false}
/>
<p style={{ margin: '0.375rem 0 0', fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>
Den API-Key erhalten Sie unter{' '}
<a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--color-primary)' }}>
console.anthropic.com
</a>
. Der Key wird verschlüsselt gespeichert und niemals zurückgegeben.
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<button
style={{
padding: '0.5rem 1.25rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: apiKey.trim() && !saveMutation.isPending ? 'pointer' : 'not-allowed',
opacity: apiKey.trim() && !saveMutation.isPending ? 1 : 0.5,
}}
onClick={() => { if (apiKey.trim()) saveMutation.mutate(apiKey); }}
disabled={!apiKey.trim() || saveMutation.isPending}
>
{saveMutation.isPending ? 'Speichern…' : 'Speichern'}
</button>
{configured && (
<button
style={{
padding: '0.5rem 1rem',
background: 'none',
color: 'var(--color-danger, #dc2626)',
border: '1px solid var(--color-danger, #dc2626)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 500,
cursor: removeMutation.isPending ? 'not-allowed' : 'pointer',
opacity: removeMutation.isPending ? 0.5 : 1,
}}
onClick={() => {
if (confirm('API-Key wirklich entfernen? Der KI-Chat wird deaktiviert.')) {
removeMutation.mutate();
}
}}
disabled={removeMutation.isPending}
>
{removeMutation.isPending ? 'Entfernen…' : 'Key entfernen'}
</button>
)}
{saved && (
<span style={{ color: 'var(--color-success, #16a34a)', fontSize: '0.875rem', fontWeight: 500 }}>
Gespeichert
</span>
)}
{(saveMutation.isError || removeMutation.isError) && (
<span style={{ color: 'var(--color-danger, #dc2626)', fontSize: '0.875rem' }}>
Fehler beim Speichern
</span>
)}
</div>
</div>
{/* Info-Box */}
<div style={{
...cardStyle,
background: 'var(--color-bg)',
marginBottom: 0,
}}>
<h3 style={{ margin: '0 0 0.75rem', fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text-secondary)' }}>
Hinweise
</h3>
<ul style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.8125rem', color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>
<li>Der KI-Hilfe-Chat nutzt <strong>Claude Haiku</strong> (kostengünstiges Modell, ~0,001 $ pro Anfrage).</li>
<li>Empfehlung: Usage-Limits im Anthropic-Dashboard setzen.</li>
<li>Eine in der Server-<code>.env</code> gesetzte Variable <code>ANTHROPIC_API_KEY</code> hat Vorrang vor dem hier hinterlegten Key.</li>
<li>Änderungen werden innerhalb von 60 Sekunden wirksam.</li>
</ul>
</div>
</div>
);
}

View file

@ -12,6 +12,7 @@ const tabs = [
{ to: '/admin/profile-access', label: 'Profilzugriff' }, { to: '/admin/profile-access', label: 'Profilzugriff' },
{ to: '/admin/crm-settings', label: 'CRM Sichtbarkeit' }, { to: '/admin/crm-settings', label: 'CRM Sichtbarkeit' },
{ to: '/admin/master-data', label: 'Stammdaten' }, { to: '/admin/master-data', label: 'Stammdaten' },
{ to: '/admin/ai-settings', label: 'KI-Einstellungen' },
]; ];
export function AdminLayout() { export function AdminLayout() {

View file

@ -1,4 +1,6 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '../../api/client'; import api from '../../api/client';
interface ChatMessage { interface ChatMessage {
@ -23,6 +25,10 @@ const HELP_TEXTS: Record<string, { title: string; text: string }> = {
title: 'Stammdaten', title: 'Stammdaten',
text: 'Verwalten Sie Referenzlisten wie Abteilungen, Standorte, Kostenstellen, Stellenbezeichnungen und Skill-Kategorien. Diese Listen werden in anderen Bereichen als Auswahloptionen verwendet.', text: 'Verwalten Sie Referenzlisten wie Abteilungen, Standorte, Kostenstellen, Stellenbezeichnungen und Skill-Kategorien. Diese Listen werden in anderen Bereichen als Auswahloptionen verwendet.',
}, },
'admin-ai-settings': {
title: 'KI-Einstellungen',
text: 'Hinterlegen Sie hier Ihren Anthropic API-Key, um den KI-Assistenten zu aktivieren. Der Key wird sicher gespeichert und niemals zurückgegeben. Ohne Key stehen nur die statischen Hilfetexte zur Verfügung.',
},
'crm-deals': { 'crm-deals': {
title: 'Deals', title: 'Deals',
text: 'Verwalten Sie Ihre Verkaufschancen. Jeder Deal hat einen Wert, eine Stage in Ihrer Pipeline und einen Status (Offen, Gewonnen, Verloren). Nutzen Sie das Kanban-Board für eine visuelle Übersicht.', text: 'Verwalten Sie Ihre Verkaufschancen. Jeder Deal hat einen Wert, eine Stage in Ihrer Pipeline und einen Status (Offen, Gewonnen, Verloren). Nutzen Sie das Kanban-Board für eine visuelle Übersicht.',
@ -49,6 +55,16 @@ export function HelpPanel({ isOpen, onClose, pageKey }: Props) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const { data: aiConfig } = useQuery({
queryKey: ['settings', 'ai-config'],
queryFn: () => api.get<{ configured: boolean }>('/settings/ai-config').then(r => r.data),
staleTime: 60_000,
enabled: isOpen,
});
const aiConfigured = aiConfig?.configured ?? true; // optimistic default until loaded
const helpContent = HELP_TEXTS[pageKey] ?? { const helpContent = HELP_TEXTS[pageKey] ?? {
title: 'Hilfe', title: 'Hilfe',
@ -141,82 +157,112 @@ export function HelpPanel({ isOpen, onClose, pageKey }: Props) {
</p> </p>
</div> </div>
{/* Messages */} {!aiConfigured ? (
<div style={{ flex: 1, overflowY: 'auto', padding: '0 1.25rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> /* AI not configured — show hint */
{messages.length === 0 && ( <div style={{
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', fontStyle: 'italic' }}> flex: 1,
Stellen Sie eine Frage zur Bedienung von INSIGHT display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
padding: '1.5rem 1.25rem', gap: '0.75rem', textAlign: 'center',
}}>
<span style={{ fontSize: '2rem' }}>🔑</span>
<p style={{ margin: 0, fontSize: '0.8125rem', color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
Der KI-Assistent ist noch nicht eingerichtet.
</p> </p>
)} <button
{messages.map((msg, i) => ( onClick={() => { onClose(); navigate('/admin/ai-settings'); }}
<div key={i} style={{ style={{
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start', padding: '0.5rem 1rem',
maxWidth: '85%', background: 'var(--color-primary)',
background: msg.role === 'user' ? 'var(--color-primary)' : 'var(--color-bg)', color: '#fff',
color: msg.role === 'user' ? '#fff' : 'var(--color-text)', border: 'none',
border: msg.role === 'assistant' ? '1px solid var(--color-border)' : 'none', borderRadius: '6px',
borderRadius: msg.role === 'user' ? '12px 12px 4px 12px' : '12px 12px 12px 4px', cursor: 'pointer',
padding: '0.5rem 0.75rem', fontSize: '0.8125rem',
fontSize: '0.8125rem', }}
lineHeight: 1.4, >
whiteSpace: 'pre-wrap', Admin: KI-Einstellungen
}}> </button>
{msg.content} </div>
) : (
<>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 1.25rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{messages.length === 0 && (
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', fontStyle: 'italic' }}>
Stellen Sie eine Frage zur Bedienung von INSIGHT
</p>
)}
{messages.map((msg, i) => (
<div key={i} style={{
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
background: msg.role === 'user' ? 'var(--color-primary)' : 'var(--color-bg)',
color: msg.role === 'user' ? '#fff' : 'var(--color-text)',
border: msg.role === 'assistant' ? '1px solid var(--color-border)' : 'none',
borderRadius: msg.role === 'user' ? '12px 12px 4px 12px' : '12px 12px 12px 4px',
padding: '0.5rem 0.75rem',
fontSize: '0.8125rem',
lineHeight: 1.4,
whiteSpace: 'pre-wrap',
}}>
{msg.content}
</div>
))}
{loading && (
<div style={{ alignSelf: 'flex-start', fontSize: '0.8125rem', color: 'var(--color-text-secondary)', fontStyle: 'italic' }}>
Denkt nach
</div>
)}
{error && (
<div style={{ fontSize: '0.8125rem', color: 'var(--color-danger, #dc2626)' }}>{error}</div>
)}
<div ref={messagesEndRef} />
</div> </div>
))}
{loading && (
<div style={{ alignSelf: 'flex-start', fontSize: '0.8125rem', color: 'var(--color-text-secondary)', fontStyle: 'italic' }}>
Denkt nach
</div>
)}
{error && (
<div style={{ fontSize: '0.8125rem', color: 'var(--color-danger, #dc2626)' }}>{error}</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */} {/* Input */}
<div style={{ <div style={{
padding: '0.75rem 1.25rem 1rem', padding: '0.75rem 1.25rem 1rem',
borderTop: '1px solid var(--color-border)', borderTop: '1px solid var(--color-border)',
display: 'flex', gap: '0.5rem', flexShrink: 0, display: 'flex', gap: '0.5rem', flexShrink: 0,
}}> }}>
<textarea <textarea
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void sendMessage(); } }} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void sendMessage(); } }}
placeholder="Frage eingeben… (Enter zum Senden)" placeholder="Frage eingeben… (Enter zum Senden)"
rows={2} rows={2}
style={{ style={{
flex: 1, flex: 1,
resize: 'none', resize: 'none',
padding: '0.5rem 0.6rem', padding: '0.5rem 0.6rem',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
borderRadius: '6px', borderRadius: '6px',
fontSize: '0.8125rem', fontSize: '0.8125rem',
background: 'var(--color-bg)', background: 'var(--color-bg)',
color: 'var(--color-text)', color: 'var(--color-text)',
fontFamily: 'inherit', fontFamily: 'inherit',
}} }}
/> />
<button <button
onClick={() => void sendMessage()} onClick={() => void sendMessage()}
disabled={!input.trim() || loading} disabled={!input.trim() || loading}
style={{ style={{
padding: '0.5rem 0.75rem', padding: '0.5rem 0.75rem',
background: 'var(--color-primary)', background: 'var(--color-primary)',
color: '#fff', color: '#fff',
border: 'none', border: 'none',
borderRadius: '6px', borderRadius: '6px',
cursor: 'pointer', cursor: 'pointer',
fontSize: '0.875rem', fontSize: '0.875rem',
alignSelf: 'flex-end', alignSelf: 'flex-end',
opacity: (!input.trim() || loading) ? 0.5 : 1, opacity: (!input.trim() || loading) ? 0.5 : 1,
}} }}
> >
</button> </button>
</div> </div>
</>
)}
</div> </div>
</> </>
); );

View file

@ -17,6 +17,7 @@ import { AdminProfileAccessPage } from '../admin/AdminProfileAccessPage';
import { AdminProfileDetailPage } from '../admin/AdminProfileDetailPage'; import { AdminProfileDetailPage } from '../admin/AdminProfileDetailPage';
import { AdminCrmSettingsPage } from '../admin/AdminCrmSettingsPage'; import { AdminCrmSettingsPage } from '../admin/AdminCrmSettingsPage';
import { AdminMasterDataPage } from '../admin/AdminMasterDataPage'; import { AdminMasterDataPage } from '../admin/AdminMasterDataPage';
import { AdminAiSettingsPage } from '../admin/AdminAiSettingsPage';
import { ProfilePage } from '../profile/ProfilePage'; import { ProfilePage } from '../profile/ProfilePage';
import { ContactsPage } from '../crm/contacts/ContactsPage'; import { ContactsPage } from '../crm/contacts/ContactsPage';
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage'; import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
@ -100,6 +101,7 @@ export function App() {
<Route path="profile-access" element={<AdminProfileAccessPage />} /> <Route path="profile-access" element={<AdminProfileAccessPage />} />
<Route path="crm-settings" element={<AdminCrmSettingsPage />} /> <Route path="crm-settings" element={<AdminCrmSettingsPage />} />
<Route path="master-data" element={<AdminMasterDataPage />} /> <Route path="master-data" element={<AdminMasterDataPage />} />
<Route path="ai-settings" element={<AdminAiSettingsPage />} />
</Route> </Route>
{/* Admin-Profildetail außerhalb des Admin-Layouts (volle Seite) */} {/* Admin-Profildetail außerhalb des Admin-Layouts (volle Seite) */}
<Route path="admin/profiles/:userId" element={<AdminProfileDetailPage />} /> <Route path="admin/profiles/:userId" element={<AdminProfileDetailPage />} />