mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
c96ccb5fcc
commit
0c8a23ddc4
8 changed files with 495 additions and 87 deletions
84
Summarize.md
84
Summarize.md
|
|
@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
198
packages/frontend/src/admin/AdminAiSettingsPage.tsx
Normal file
198
packages/frontend/src/admin/AdminAiSettingsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue