- market_db_manager.py: Made DB_PATH configurable via environment variable. - Dockerfile.b2b: Included market_db_manager.py in the B2B container image. - docker-compose.yml: Configured separate DB paths and volumes for Market Intel and B2B Assistant. - B2B Server: Added API routes for project management (list, load, save, delete). - B2B UI: Implemented auto-save and a 'Project History' modal for loading past runs.
655 lines
32 KiB
TypeScript
655 lines
32 KiB
TypeScript
|
|
import React, { useState, useCallback, useEffect } from 'react';
|
|
import { InputForm } from './components/InputForm';
|
|
import { StepDisplay } from './components/StepDisplay';
|
|
import { LoadingSpinner, BotIcon, SparklesIcon, MarkdownIcon, PrintIcon } from './components/Icons';
|
|
import { ExportMenu } from './components/ExportMenu';
|
|
import { translations } from './constants';
|
|
import type { AnalysisStep, AnalysisData, InputData, Project, ProjectMetadata } from './types';
|
|
import { generateMarkdown, downloadFile } from './services/export';
|
|
import { History, Clock, Trash2, X, ArrowRight, FolderOpen } from 'lucide-react';
|
|
|
|
const API_BASE_URL = 'api';
|
|
|
|
// --- DB HELPERS ---
|
|
const listProjects = async (): Promise<ProjectMetadata[]> => {
|
|
const response = await fetch(`${API_BASE_URL}/projects`);
|
|
if (!response.ok) throw new Error("Failed to list projects");
|
|
return await response.json();
|
|
};
|
|
|
|
const loadProjectData = async (id: string): Promise<Project> => {
|
|
const response = await fetch(`${API_BASE_URL}/projects/${id}`);
|
|
if (!response.ok) throw new Error("Failed to load project");
|
|
return await response.json();
|
|
};
|
|
|
|
const saveProject = async (data: any): Promise<any> => {
|
|
const response = await fetch(`${API_BASE_URL}/save-project`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
if (!response.ok) throw new Error("Failed to save project");
|
|
return await response.json();
|
|
};
|
|
|
|
const deleteProject = async (id: string): Promise<any> => {
|
|
const response = await fetch(`${API_BASE_URL}/projects/${id}`, {
|
|
method: 'DELETE'
|
|
});
|
|
if (!response.ok) throw new Error("Failed to delete project");
|
|
return await response.json();
|
|
};
|
|
|
|
|
|
const App: React.FC = () => {
|
|
const [inputData, setInputData] = useState<InputData>({
|
|
companyUrl: '',
|
|
language: 'de',
|
|
regions: '',
|
|
focus: '',
|
|
channels: ['LinkedIn', 'Kaltmail', 'Landingpage']
|
|
});
|
|
const [analysisData, setAnalysisData] = useState<Partial<AnalysisData>>({});
|
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [generationStep, setGenerationStep] = useState<number>(0); // 0: idle, 1-6: step X is complete
|
|
const [selectedIndustry, setSelectedIndustry] = useState<string>('');
|
|
const [batchStatus, setBatchStatus] = useState<{ current: number; total: number; industry: string } | null>(null);
|
|
|
|
// Project Persistence
|
|
const [projectId, setProjectId] = useState<string | null>(null);
|
|
const [projectName, setProjectName] = useState<string>('');
|
|
const [showHistory, setShowHistory] = useState(false);
|
|
const [recentProjects, setRecentProjects] = useState<ProjectMetadata[]>([]);
|
|
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
|
|
|
const t = translations[inputData.language];
|
|
const STEP_TITLES = t.stepTitles;
|
|
const STEP_KEYS: (keyof AnalysisData)[] = ['offer', 'targetGroups', 'personas', 'painPoints', 'gains', 'messages'];
|
|
|
|
// --- AUTO-SAVE EFFECT ---
|
|
useEffect(() => {
|
|
if (generationStep === 0 || !inputData.companyUrl) return;
|
|
|
|
const saveData = async () => {
|
|
let dynamicName = projectName;
|
|
try {
|
|
const urlObj = new URL(inputData.companyUrl.startsWith('http') ? inputData.companyUrl : `https://${inputData.companyUrl}`);
|
|
const host = urlObj.hostname.replace('www.', '');
|
|
|
|
if (generationStep >= 1) {
|
|
dynamicName = `${host} (Step ${generationStep})`;
|
|
} else {
|
|
dynamicName = `Draft: ${host}`;
|
|
}
|
|
} catch (e) {
|
|
dynamicName = projectName || "Untitled Project";
|
|
}
|
|
|
|
const dataToSave = {
|
|
id: projectId,
|
|
name: dynamicName,
|
|
currentStep: generationStep,
|
|
language: inputData.language,
|
|
inputs: inputData,
|
|
analysisData: analysisData
|
|
};
|
|
|
|
try {
|
|
const result = await saveProject(dataToSave);
|
|
if (result.id && !projectId) {
|
|
setProjectId(result.id);
|
|
}
|
|
} catch (e) {
|
|
console.error("Auto-save failed", e);
|
|
}
|
|
};
|
|
|
|
const timer = setTimeout(saveData, 2000);
|
|
return () => clearTimeout(timer);
|
|
}, [generationStep, analysisData, inputData, projectId, projectName]);
|
|
|
|
// --- DB ACTIONS ---
|
|
const fetchProjects = useCallback(async () => {
|
|
setIsLoadingProjects(true);
|
|
try {
|
|
const projects = await listProjects();
|
|
setRecentProjects(projects);
|
|
} catch (e) {
|
|
console.error("Failed to load projects", e);
|
|
} finally {
|
|
setIsLoadingProjects(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (showHistory) fetchProjects();
|
|
}, [showHistory, fetchProjects]);
|
|
|
|
const handleProjectSelect = async (id: string) => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const project = await loadProjectData(id);
|
|
setProjectId(project.id);
|
|
setProjectName(project.name);
|
|
setInputData(project.inputs);
|
|
setAnalysisData(project.analysisData);
|
|
setGenerationStep(project.currentStep);
|
|
setShowHistory(false);
|
|
} catch (e) {
|
|
console.error(e);
|
|
setError("Fehler beim Laden des Projekts.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteProject = async (e: React.MouseEvent, id: string) => {
|
|
e.stopPropagation();
|
|
if (confirm(t.language === 'de' ? "Projekt wirklich löschen?" : "Delete project?")) {
|
|
try {
|
|
await deleteProject(id);
|
|
fetchProjects();
|
|
if (id === projectId) {
|
|
handleRestart();
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert("Fehler beim Löschen.");
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRestart = () => {
|
|
setProjectId(null);
|
|
setProjectName('');
|
|
setAnalysisData({});
|
|
setGenerationStep(0);
|
|
setSelectedIndustry('');
|
|
setBatchStatus(null);
|
|
setError(null);
|
|
};
|
|
|
|
|
|
const handleStartGeneration = useCallback(async () => {
|
|
if (!inputData.companyUrl) {
|
|
setError('Bitte geben Sie eine Unternehmens-URL ein.');
|
|
return;
|
|
}
|
|
setIsLoading(true);
|
|
setError(null);
|
|
setAnalysisData({});
|
|
setGenerationStep(0);
|
|
setSelectedIndustry('');
|
|
setBatchStatus(null);
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}/start-generation`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
companyUrl: inputData.companyUrl,
|
|
language: inputData.language,
|
|
regions: inputData.regions,
|
|
focus: inputData.focus,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.details || `HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const parsedData = await response.json();
|
|
|
|
if (parsedData.error) {
|
|
throw new Error(parsedData.error);
|
|
}
|
|
|
|
setAnalysisData(parsedData);
|
|
setGenerationStep(1);
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
setError(e instanceof Error ? `Ein Fehler ist aufgetreten: ${e.message}` : 'Ein unbekannter Fehler ist aufgetreten.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [inputData]);
|
|
|
|
const handleGenerateNextStep = useCallback(async () => {
|
|
if (generationStep >= 6) return;
|
|
|
|
if (generationStep === 5 && !selectedIndustry) {
|
|
setError('Bitte wählen Sie eine Fokus-Branche aus.');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}/next-step`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
analysisData,
|
|
language: inputData.language,
|
|
channels: inputData.channels,
|
|
generationStep: generationStep + 1, // Pass the step we want to generate
|
|
focusIndustry: generationStep === 5 ? selectedIndustry : undefined,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.details || `HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const parsedData = await response.json();
|
|
|
|
if (parsedData.error) {
|
|
throw new Error(parsedData.error);
|
|
}
|
|
|
|
setAnalysisData(prev => ({ ...prev, ...parsedData }));
|
|
setGenerationStep(prev => prev + 1);
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
setError(e instanceof Error ? `Ein Fehler ist aufgetreten: ${e.message}` : 'Ein unbekannter Fehler ist aufgetreten.');
|
|
setGenerationStep(prev => prev); // Stay on the current step if error
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [analysisData, generationStep, inputData.channels, inputData.language, selectedIndustry]);
|
|
|
|
const handleBatchGenerate = useCallback(async () => {
|
|
if (!analysisData.targetGroups?.rows) return;
|
|
|
|
const industries = analysisData.targetGroups.rows.map(row => row[0]);
|
|
if (industries.length === 0) return;
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
setGenerationStep(6); // Show the Step 6 container (will be filled incrementally)
|
|
|
|
// Initialize Step 6 data container
|
|
setAnalysisData(prev => ({
|
|
...prev,
|
|
messages: {
|
|
summary: ["Batch-Analyse aller Branchen läuft..."],
|
|
headers: ["Fokus-Branche", "Rolle", "Kernbotschaft", "Kanäle"], // Default headers, will be overwritten/verified
|
|
rows: []
|
|
}
|
|
}));
|
|
|
|
let aggregatedRows: string[][] = [];
|
|
let capturedHeaders: string[] = [];
|
|
|
|
for (let i = 0; i < industries.length; i++) {
|
|
const industry = industries[i];
|
|
setBatchStatus({ current: i + 1, total: industries.length, industry });
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}/next-step`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
analysisData, // Pass full context
|
|
language: inputData.language,
|
|
channels: inputData.channels,
|
|
generationStep: 6,
|
|
focusIndustry: industry,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) throw new Error(`HTTP error for ${industry}`);
|
|
const data = await response.json();
|
|
|
|
if (data.messages && data.messages.rows) {
|
|
if (capturedHeaders.length === 0 && data.messages.headers) {
|
|
capturedHeaders = data.messages.headers;
|
|
}
|
|
aggregatedRows = [...aggregatedRows, ...data.messages.rows];
|
|
|
|
// Update state incrementally so user sees results growing
|
|
setAnalysisData(prev => ({
|
|
...prev,
|
|
messages: {
|
|
summary: ["Vollständige Analyse über alle identifizierten Branchen."],
|
|
headers: capturedHeaders.length > 0 ? capturedHeaders : (prev?.messages?.headers || []),
|
|
rows: aggregatedRows
|
|
}
|
|
}));
|
|
}
|
|
|
|
} catch (e) {
|
|
console.error(`Error processing industry ${industry}:`, e);
|
|
// We continue with next industry even if one fails
|
|
}
|
|
}
|
|
|
|
setIsLoading(false);
|
|
setBatchStatus(null);
|
|
}, [analysisData, inputData]);
|
|
|
|
const handleDataChange = <K extends keyof AnalysisData>(step: K, newData: AnalysisData[K]) => {
|
|
if (analysisData[step]) {
|
|
setAnalysisData(prev => prev ? { ...prev, [step]: newData } : { [step]: newData });
|
|
}
|
|
};
|
|
|
|
const handleDownloadMarkdown = () => {
|
|
if (!analysisData) return;
|
|
const markdownContent = generateMarkdown(analysisData as AnalysisData, STEP_TITLES, t.summaryTitle);
|
|
downloadFile(markdownContent, 'b2b-marketing-analysis.md', 'text/markdown;charset=utf-8');
|
|
};
|
|
|
|
const handlePrint = () => {
|
|
window.print();
|
|
};
|
|
|
|
const renderStep = (stepKey: keyof AnalysisData, title: string) => {
|
|
const step = analysisData[stepKey] as AnalysisStep | undefined;
|
|
if (!step) return null;
|
|
|
|
const canDelete = ['offer', 'targetGroups', 'personas'].includes(stepKey);
|
|
|
|
return (
|
|
<StepDisplay
|
|
key={stepKey}
|
|
title={title}
|
|
summary={step.summary}
|
|
headers={step.headers}
|
|
rows={step.rows}
|
|
onDataChange={(newRows) => handleDataChange(stepKey, { ...step, rows: newRows })}
|
|
canAddRows={false} // Disabled enrich functionality
|
|
onEnrichRow={undefined}
|
|
isEnriching={false}
|
|
canDeleteRows={canDelete}
|
|
t={t}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderContinueButton = (stepNumber: number) => {
|
|
if (isLoading || generationStep !== stepNumber - 1) return null;
|
|
|
|
// Industry Selector Logic for Step 6
|
|
if (stepNumber === 6 && analysisData.targetGroups?.rows) {
|
|
const industries = analysisData.targetGroups.rows.map(row => row[0]); // Assume Col 0 is Industry
|
|
return (
|
|
<div className="my-8 max-w-2xl mx-auto bg-white dark:bg-slate-800 p-6 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 print:hidden">
|
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4 text-center">
|
|
Wählen Sie eine Fokus-Branche für Schritt 6 (Botschaften):
|
|
</h3>
|
|
|
|
<div className="flex justify-center mb-6">
|
|
<button
|
|
onClick={handleBatchGenerate}
|
|
className="flex items-center px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white rounded-md text-sm font-medium transition-colors shadow-sm"
|
|
>
|
|
<SparklesIcon className="mr-2 h-4 w-4 text-yellow-400" />
|
|
Alle {industries.length} Branchen analysieren (Batch)
|
|
</button>
|
|
</div>
|
|
|
|
<div className="text-center text-xs text-slate-500 mb-2 uppercase font-semibold tracking-wider">- ODER EINZELN -</div>
|
|
|
|
<div className="space-y-2 mb-6 max-h-60 overflow-y-auto pr-2">
|
|
{industries.map((ind, idx) => (
|
|
<label key={idx} className={`flex items-center p-3 rounded-lg border cursor-pointer transition-all ${selectedIndustry === ind ? 'bg-sky-50 border-sky-500 ring-1 ring-sky-500 dark:bg-sky-900/20 dark:border-sky-400' : 'border-slate-200 hover:border-slate-300 dark:border-slate-700 dark:hover:border-slate-600'}`}>
|
|
<input
|
|
type="radio"
|
|
name="industry"
|
|
value={ind}
|
|
checked={selectedIndustry === ind}
|
|
onChange={(e) => { setSelectedIndustry(e.target.value); setError(null); }}
|
|
className="h-4 w-4 text-sky-600 focus:ring-sky-500 border-gray-300 dark:bg-slate-700 dark:border-slate-600"
|
|
/>
|
|
<span className="ml-3 block text-sm font-medium text-slate-700 dark:text-slate-200 break-words">
|
|
{ind}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div className="text-center">
|
|
<button
|
|
onClick={handleGenerateNextStep}
|
|
disabled={!selectedIndustry}
|
|
className="flex items-center justify-center w-full sm:w-auto mx-auto px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:bg-slate-400 dark:disabled:bg-slate-600 disabled:cursor-not-allowed transition-colors duration-200"
|
|
>
|
|
<SparklesIcon className="mr-2 h-5 w-5" />
|
|
{t.continueButton.replace('{{step}}', (stepNumber - 1).toString()).replace('{{nextStep}}', stepNumber.toString())}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="my-8 text-center print:hidden">
|
|
<button
|
|
onClick={handleGenerateNextStep}
|
|
className="flex items-center justify-center w-auto mx-auto px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:bg-slate-400 dark:disabled:bg-slate-600 disabled:cursor-not-allowed transition-colors duration-200"
|
|
>
|
|
<SparklesIcon className="mr-2 h-5 w-5" />
|
|
{t.continueButton.replace('{{step}}', (stepNumber - 1).toString()).replace('{{nextStep}}', stepNumber.toString())}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 text-slate-800 dark:text-slate-200 font-sans">
|
|
<main className="container mx-auto px-4 py-8 md:py-12">
|
|
<header className="text-center mb-10 print:hidden relative">
|
|
<h1 className="text-4xl md:text-5xl font-extrabold text-slate-900 dark:text-white">
|
|
{t.appTitle}
|
|
</h1>
|
|
<p className="mt-4 text-lg text-slate-600 dark:text-slate-400">
|
|
{t.appSubtitle}
|
|
</p>
|
|
|
|
<div className="mt-6 flex justify-center gap-3">
|
|
<button
|
|
onClick={() => setShowHistory(true)}
|
|
className="flex items-center px-4 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-lg text-sm font-bold transition-all shadow-sm border border-slate-200 dark:border-slate-700"
|
|
>
|
|
<History className="mr-2 h-4 w-4 text-indigo-500" />
|
|
{inputData.language === 'de' ? 'Historie laden' : 'Load History'}
|
|
</button>
|
|
{generationStep > 0 && (
|
|
<button
|
|
onClick={handleRestart}
|
|
className="flex items-center px-4 py-2 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400 rounded-lg text-sm font-medium transition-all shadow-sm border border-slate-200 dark:border-slate-700"
|
|
>
|
|
<X className="mr-2 h-4 w-4" />
|
|
{inputData.language === 'de' ? 'Neu starten' : 'Restart'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
<div className="max-w-4xl mx-auto bg-white dark:bg-slate-800/50 rounded-2xl shadow-lg p-6 md:p-8 border border-slate-200 dark:border-slate-700 print:hidden">
|
|
<InputForm
|
|
inputData={inputData}
|
|
setInputData={setInputData}
|
|
onGenerate={handleStartGeneration}
|
|
isLoading={isLoading && generationStep === 0}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="max-w-4xl mx-auto mt-8 bg-red-100 dark:bg-red-900/50 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-200 px-4 py-3 rounded-lg print:hidden" role="alert">
|
|
<strong className="font-bold">{t.errorTitle} </strong>
|
|
<span className="block sm:inline">{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-12 space-y-12">
|
|
{isLoading && (
|
|
<div className="flex flex-col items-center justify-center text-center p-8">
|
|
<LoadingSpinner />
|
|
<p className="mt-4 text-lg text-slate-600 dark:text-slate-400 animate-pulse">
|
|
{batchStatus
|
|
? `Analysiere Branche ${batchStatus.current} von ${batchStatus.total}: ${batchStatus.industry}...`
|
|
: t.generatingStep.replace('{{stepTitle}}', STEP_TITLES[STEP_KEYS[generationStep]])}
|
|
</p>
|
|
{batchStatus && (
|
|
<div className="w-64 h-2 bg-slate-200 rounded-full mt-4 overflow-hidden">
|
|
<div
|
|
className="h-full bg-sky-500 transition-all duration-500 ease-out"
|
|
style={{ width: `${(batchStatus.current / batchStatus.total) * 100}%` }}
|
|
></div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && generationStep === 0 && !error && (
|
|
<div className="text-center p-8 bg-white dark:bg-slate-800/50 rounded-2xl shadow-md border border-slate-200 dark:border-slate-700 print:hidden">
|
|
<BotIcon className="mx-auto h-16 w-16 text-sky-500" />
|
|
<h3 className="mt-4 text-xl font-semibold text-slate-900 dark:text-white">{t.readyTitle}</h3>
|
|
<p className="mt-2 text-slate-600 dark:text-slate-400">{t.readyText}</p>
|
|
</div>
|
|
)}
|
|
|
|
{generationStep > 0 && (
|
|
<>
|
|
{generationStep >= 6 && (
|
|
<div className="flex justify-end mb-8 print:hidden">
|
|
<ExportMenu
|
|
onDownloadMarkdown={handleDownloadMarkdown}
|
|
onPrint={handlePrint}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{renderStep('offer', STEP_TITLES.offer)}
|
|
{renderContinueButton(2)}
|
|
{generationStep >= 2 && renderStep('targetGroups', STEP_TITLES.targetGroups)}
|
|
{renderContinueButton(3)}
|
|
{generationStep >= 3 && renderStep('personas', STEP_TITLES.personas)}
|
|
{renderContinueButton(4)}
|
|
{generationStep >= 4 && renderStep('painPoints', STEP_TITLES.painPoints)}
|
|
{renderContinueButton(5)}
|
|
{generationStep >= 5 && renderStep('gains', STEP_TITLES.gains)}
|
|
{renderContinueButton(6)}
|
|
{generationStep >= 6 && renderStep('messages', STEP_TITLES.messages)}
|
|
|
|
|
|
{generationStep === 6 && !isLoading && (
|
|
<div className="p-8 bg-sky-50 dark:bg-sky-900/20 rounded-2xl border border-sky-100 dark:border-sky-800 text-center print:hidden">
|
|
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-sky-100 dark:bg-sky-800 mb-4">
|
|
<SparklesIcon className="h-6 w-6 text-sky-600 dark:text-sky-300" />
|
|
</div>
|
|
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-2">
|
|
{t.analysisCompleteTitle}
|
|
</h3>
|
|
<p className="text-slate-600 dark:text-slate-300 mb-8 max-w-2xl mx-auto">
|
|
{t.analysisCompleteText.replace('{{otherLanguage}}', t.otherLanguage)}
|
|
</p>
|
|
|
|
<div className="flex flex-col sm:flex-row justify-center items-center gap-4 mb-6">
|
|
<button
|
|
onClick={handleDownloadMarkdown}
|
|
className="w-full sm:w-auto flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-slate-900 dark:bg-sky-600 hover:bg-slate-800 dark:hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 shadow-md transition-all"
|
|
>
|
|
<MarkdownIcon className="mr-2 h-5 w-5" />
|
|
{t.exportAsMarkdown}
|
|
</button>
|
|
<button
|
|
onClick={handlePrint}
|
|
className="w-full sm:w-auto flex items-center justify-center px-6 py-3 border border-slate-300 dark:border-slate-600 text-base font-medium rounded-lg text-slate-700 dark:text-slate-200 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 shadow-sm transition-all"
|
|
>
|
|
<PrintIcon className="mr-2 h-5 w-5" />
|
|
{t.exportAsPdf}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="border-t border-sky-200 dark:border-sky-700/50 pt-6 mt-2">
|
|
<p className="text-sm text-slate-500 dark:text-slate-400 mb-3">Ergebnis verfeinern?</p>
|
|
<button
|
|
onClick={() => setGenerationStep(5)}
|
|
className="text-sm text-sky-600 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-200 font-medium underline decoration-sky-300 hover:decoration-sky-600 transition-colors"
|
|
>
|
|
Schritt 6 (Botschaften) mit Fokus auf eine Branche neu generieren
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
{/* History Modal */}
|
|
{showHistory && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm print:hidden">
|
|
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh] border border-slate-200 dark:border-slate-800">
|
|
<div className="p-4 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center bg-slate-50 dark:bg-slate-800/50">
|
|
<h3 className="font-bold text-slate-900 dark:text-white text-lg flex items-center gap-2">
|
|
<History className="text-indigo-500" size={20} />
|
|
{inputData.language === 'de' ? 'Projekt-Historie' : 'Project History'}
|
|
</h3>
|
|
<button onClick={() => setShowHistory(false)} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 p-1 transition-colors">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
{isLoadingProjects ? (
|
|
<div className="flex flex-col items-center justify-center p-12">
|
|
<LoadingSpinner />
|
|
<p className="mt-4 text-slate-500 text-sm">Lade Projekte...</p>
|
|
</div>
|
|
) : recentProjects.length > 0 ? (
|
|
recentProjects.map((p) => (
|
|
<button
|
|
key={p.id}
|
|
onClick={() => handleProjectSelect(p.id)}
|
|
className="w-full text-left p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 border border-slate-200 dark:border-slate-800 hover:border-indigo-200 dark:hover:border-indigo-700 transition-all group relative flex items-center justify-between"
|
|
>
|
|
<div className="flex-1 min-w-0 pr-4">
|
|
<div className="font-semibold text-slate-800 dark:text-slate-100 group-hover:text-indigo-700 dark:group-hover:text-indigo-300 truncate">{p.name}</div>
|
|
<div className="flex items-center gap-2 mt-1.5 text-xs text-slate-500 dark:text-slate-400">
|
|
<Clock size={12} />
|
|
<span>{new Date(p.updated_at).toLocaleDateString()} {new Date(p.updated_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
onClick={(e) => handleDeleteProject(e, p.id)}
|
|
className="p-2 text-slate-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-full transition-colors z-10"
|
|
title="Delete Project"
|
|
>
|
|
<Trash2 size={18} />
|
|
</span>
|
|
<ArrowRight className="text-indigo-300 group-hover:text-indigo-500 transition-colors" size={20} />
|
|
</div>
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="text-center text-slate-400 dark:text-slate-500 py-12">
|
|
<History size={48} className="mx-auto mb-4 opacity-20" />
|
|
<p>{inputData.language === 'de' ? 'Keine vergangenen Projekte gefunden.' : 'No past projects found.'}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App;
|