Files
Brancheneinstufung2/fotograf-de-scraper/frontend/src/App.tsx

641 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import './App.css';
interface Job {
id: string;
name: string;
url: string;
status: string;
date: string;
shooting_type: string;
last_updated?: string;
}
type AccountType = 'kiga' | 'schule';
function App() {
const [activeTab, setActiveTab] = useState<AccountType>('kiga');
const [jobsCache, setJobsCache] = useState<Record<AccountType, Job[] | null>>({
kiga: null,
schule: null,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// New state for the detail modal
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
// States for Statistics Polling
const [statsTaskId, setStatsTaskId] = useState<string | null>(null);
const [statsProgress, setStatsProgress] = useState<string>('');
const [statsResult, setStatsResult] = useState<any[] | null>(null);
const [isStatsRunning, setIsStatsRunning] = useState(false);
// States for QR Generator
const [isQrGenerating, setIsQrGenerating] = useState(false);
const [eventTypes, setEventTypes] = useState<any[]>([]);
const [selectedEventType, setSelectedEventType] = useState<string>("");
const [isListGenerating, setIsListGenerating] = useState(false);
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002';
const fetchJobs = async (account: AccountType, forceRefresh = false) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/jobs?account_type=${account}&force_refresh=${forceRefresh}`);
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.detail || 'Fehler beim Abrufen der Aufträge');
}
const data: Job[] = await response.json();
setJobsCache(prev => ({ ...prev, [account]: data }));
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
// Only fetch if we haven't already fetched it
if (jobsCache[activeTab] === null) {
fetchJobs(activeTab, false);
}
}, [activeTab]);
const handleRefresh = () => fetchJobs(activeTab, true);
useEffect(() => {
const fetchEventTypes = async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/calendly/event-types`);
if (res.ok) {
const data = await res.json();
setEventTypes(data.event_types || []);
if (data.event_types && data.event_types.length > 0) {
setSelectedEventType(data.event_types[0].name);
}
}
} catch (err) {
console.error("Failed to fetch event types:", err);
}
};
fetchEventTypes();
}, []);
// Statistics Task Functions
const handleStartStatistics = async (job: Job) => {
setIsStatsRunning(true);
setStatsResult(null);
setStatsProgress('Starte Analyse...');
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/statistics?account_type=${activeTab}`, {
method: 'POST'
});
if (!response.ok) throw new Error('Konnte Statistik-Prozess nicht starten.');
const data = await response.json();
setStatsTaskId(data.task_id);
} catch (err: any) {
setError(err.message);
setIsStatsRunning(false);
}
};
useEffect(() => {
let interval: any;
if (statsTaskId && isStatsRunning) {
interval = setInterval(async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/tasks/${statsTaskId}`);
if (!res.ok) throw new Error('Task Status Request failed');
const data = await res.json();
setStatsProgress(data.progress || 'Verarbeite...');
if (data.status === 'completed') {
setStatsResult(data.result);
setIsStatsRunning(false);
setStatsTaskId(null);
} else if (data.status === 'error') {
setError(data.progress || 'Ein Fehler ist aufgetreten.');
setIsStatsRunning(false);
setStatsTaskId(null);
}
} catch (err: any) {
console.error("Polling Error:", err);
}
}, 1000);
}
return () => {
if (interval) clearInterval(interval);
};
}, [statsTaskId, isStatsRunning]);
const handleGeneratePdf = async (job: Job) => {
setProcessingJobId(job.id);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/generate-pdf?account_type=${activeTab}`);
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.detail || 'PDF Generierung fehlgeschlagen');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Listen_${job.name.replace(/\s+/g, "_")}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (err: any) {
setError(`PDF Fehler (${job.name}): ${err.message}`);
} finally {
setProcessingJobId(null);
}
};
const handleGenerateQrCards = async (job: Job, file: File) => {
if (!file) {
setError("Bitte wähle eine PDF-Vorlage aus.");
return;
}
setIsQrGenerating(true);
setError(null);
const formData = new FormData();
formData.append('pdf_file', file);
if (selectedEventType) formData.append('event_type_name', selectedEventType);
try {
const response = await fetch(`${API_BASE_URL}/api/qr-cards/generate`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
if (response.status === 404) {
throw new Error("Keine passenden Calendly-Termine gefunden.");
}
const errData = await response.json();
throw new Error(errData.detail || 'Generierung fehlgeschlagen');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `QR_Karten_Andruck_${job.id}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (err: any) {
setError(err.message);
} finally {
setIsQrGenerating(false);
}
};
const handleGenerateAppointmentList = async (job: Job) => {
setIsListGenerating(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/appointment-list?event_type_name=${encodeURIComponent(selectedEventType)}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error("Keine passenden Calendly-Termine gefunden.");
}
const errData = await response.json();
throw new Error(errData.detail || 'PDF Generierung fehlgeschlagen');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Terminuebersicht_${job.name.replace(/\s+/g, "_")}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (err: any) {
setError(`Listen-Fehler (${job.name}): ${err.message}`);
} finally {
setIsListGenerating(false);
}
};
const currentJobs = jobsCache[activeTab];
return (
<div className="min-h-screen bg-gray-50 text-gray-900 font-sans">
{/* Top Navigation Bar */}
<header className="bg-white shadow-sm sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">📸</span>
<h1 className="text-xl font-bold text-gray-800 tracking-tight">Fotograf.de ERP</h1>
</div>
{/* Main Tabs */}
<nav className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
<button
onClick={() => setActiveTab('kiga')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeTab === 'kiga' ? 'bg-white shadow text-indigo-700' : 'text-gray-500 hover:text-gray-700'
}`}
>
Kindergarten
</button>
<button
onClick={() => setActiveTab('schule')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeTab === 'schule' ? 'bg-white shadow text-indigo-700' : 'text-gray-500 hover:text-gray-700'
}`}
>
Schule
</button>
</nav>
</div>
</header>
{/* Main Content Area */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Action Bar */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900">
{activeTab === 'kiga' ? 'Kindergarten Aufträge' : 'Schul Aufträge'}
</h2>
<p className="text-sm text-gray-500 mt-1">
{currentJobs === null ? "Noch nicht geladen." : `${currentJobs.length} aktive Projekte gefunden.`}
</p>
</div>
<button
onClick={handleRefresh}
disabled={isLoading}
className="flex items-center gap-2 px-5 py-2.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 focus:ring-4 focus:ring-indigo-100 transition-all disabled:opacity-70 disabled:cursor-not-allowed shadow-sm"
>
{isLoading ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Synchronisiere...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
{currentJobs === null ? 'Aufträge abrufen' : 'Liste aktualisieren'}
</>
)}
</button>
</div>
{/* Global Errors */}
{error && !selectedJob && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-8 flex items-start gap-3">
<svg className="w-5 h-5 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" /></svg>
<div>
<h3 className="font-semibold text-sm">Systemfehler</h3>
<p className="text-sm mt-1">{error}</p>
</div>
</div>
)}
{/* Empty State */}
{currentJobs !== null && currentJobs.length === 0 && !isLoading && !error && (
<div className="text-center py-16 bg-white rounded-xl border border-dashed border-gray-300">
<svg className="mx-auto h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path vectorEffect="non-scaling-stroke" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" /></svg>
<h3 className="mt-2 text-sm font-semibold text-gray-900">Keine Projekte</h3>
<p className="mt-1 text-sm text-gray-500">Es wurden keine aktiven Aufträge für diesen Account gefunden.</p>
</div>
)}
{/* Grid of Job Cards */}
{currentJobs !== null && currentJobs.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{currentJobs.map((job) => (
<div
key={job.id}
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md hover:border-indigo-300 transition-all duration-200 cursor-pointer flex flex-col h-full"
onClick={() => setSelectedJob(job)}
>
{/* Card Header (Status Color Band) */}
<div className={`h-2 w-full ${job.status.toLowerCase().includes('abgeschlossen') ? 'bg-gray-300' : 'bg-indigo-500'}`}></div>
<div className="p-6 flex-1 flex flex-col">
<div className="flex justify-between items-start mb-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{job.id ? `ID: ${job.id}` : 'Unbekannte ID'}
</span>
<span className="text-sm font-medium text-gray-500 bg-gray-50 px-2 py-1 rounded">
{job.date || 'Kein Datum'}
</span>
</div>
<h3 className="text-lg font-bold text-gray-900 leading-tight mb-2 line-clamp-2">
{job.name}
</h3>
<div className="mt-auto pt-4 flex flex-col gap-2">
<p className="text-sm text-gray-600 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
{job.status}
</p>
<p className="text-xs text-gray-400">Typ: {job.shooting_type}</p>
</div>
</div>
{/* Card Footer */}
<div className="bg-gray-50 px-6 py-3 border-t border-gray-100 flex justify-between items-center">
<span className="text-sm font-medium text-indigo-600 group-hover:text-indigo-800">
Details verwalten &rarr;
</span>
</div>
</div>
))}
</div>
)}
</main>
{/* --- Detail Modal Overlay --- */}
{selectedJob && (
<div className="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
{/* Background backdrop */}
<div className="fixed inset-0 bg-gray-900 bg-opacity-75 transition-opacity" aria-hidden="true" onClick={() => setSelectedJob(null)}></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
{/* Modal Panel */}
<div className="inline-block align-bottom bg-gray-50 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl w-full">
{/* Modal Header */}
<div className="bg-white px-6 py-6 border-b border-gray-200 flex justify-between items-start">
<div>
<h3 className="text-2xl font-bold text-gray-900" id="modal-title">
{selectedJob.name}
</h3>
<p className="text-sm text-gray-500 mt-1 flex items-center gap-3">
<span>📅 {selectedJob.date}</span>
<span></span>
<span className="text-emerald-600 font-medium">{selectedJob.status}</span>
<span></span>
<a href={selectedJob.url} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:underline inline-flex items-center gap-1">
In Fotograf.de öffnen <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
</p>
</div>
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={() => setSelectedJob(null)}
>
<span className="sr-only">Schließen</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Modal Body - Tools & Actions Grid */}
<div className="px-6 py-8">
{error && processingJobId === selectedJob.id && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
{error}
</div>
)}
<h4 className="text-lg font-bold text-gray-800 mb-4">Verfügbare Tools</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Tool 1: PDF List */}
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:border-blue-300 transition-colors shadow-sm">
<div className="flex items-start justify-between mb-2">
<div className="p-2 bg-blue-50 rounded-lg text-blue-600 text-xl">📄</div>
</div>
<h5 className="font-bold text-gray-900 mb-1">Teilnehmerliste (PDF)</h5>
<p className="text-sm text-gray-500 mb-4 line-clamp-2">Lädt die Anmeldungen herunter und formatiert sie als sauberes PDF (Gruppen/Klassen sortiert).</p>
<button
onClick={() => handleGeneratePdf(selectedJob)}
disabled={processingJobId === selectedJob.id || isStatsRunning}
className="w-full flex justify-center items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 focus:ring-4 focus:ring-blue-100 disabled:opacity-50 transition-colors"
>
{processingJobId === selectedJob.id ? 'Lädt CSV via Selenium...' : 'PDF generieren & speichern'}
</button>
</div>
{/* Tool 2: Shooting-Planung (QR & List) */}
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:border-emerald-300 transition-colors shadow-sm md:col-span-2">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-emerald-50 rounded-lg text-emerald-600 text-xl">📆</div>
<h5 className="font-bold text-gray-900 text-lg">Shooting-Planung</h5>
</div>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-800">Neu</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Event Selection */}
<div className="md:col-span-1 border-r border-gray-100 pr-4">
<label className="block text-sm font-bold text-gray-700 mb-2">Calendly Event auswählen</label>
<select
value={selectedEventType}
onChange={(e) => setSelectedEventType(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-sm"
>
{eventTypes.length === 0 && <option value="">Lade Events...</option>}
{eventTypes.map(et => (
<option key={et.uri} value={et.name}>{et.name}</option>
))}
</select>
<p className="text-xs text-gray-500 mt-2">Die Termine für diesen Event-Typ werden aus Calendly importiert.</p>
</div>
{/* Actions */}
<div className="md:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Action 1: QR Cards */}
<div className="bg-gray-50 p-4 rounded-lg flex flex-col justify-between">
<div>
<h6 className="font-bold text-sm text-gray-800 mb-1">📇 QR-Zugangskarten</h6>
<p className="text-xs text-gray-600 mb-3">Druckt Namen und Uhrzeit auf vorbereitete PDF-Bögen.</p>
<input
type="file"
accept=".pdf"
id={`qr-upload-${selectedJob.id}`}
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
handleGenerateQrCards(selectedJob, e.target.files[0]);
e.target.value = ''; // reset
}
}}
/>
</div>
<button
onClick={() => document.getElementById(`qr-upload-${selectedJob.id}`)?.click()}
disabled={!selectedEventType || isQrGenerating}
className="w-full px-3 py-2 bg-emerald-600 text-white text-xs font-bold rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-all flex justify-center items-center gap-2 mt-auto"
>
{isQrGenerating ? 'Generiere...' : 'Blanko PDF hochladen & starten'}
</button>
</div>
{/* Action 2: Appointment List */}
<div className="bg-gray-50 p-4 rounded-lg flex flex-col justify-between">
<div>
<h6 className="font-bold text-sm text-gray-800 mb-1">📄 Termin-Übersicht</h6>
<p className="text-xs text-gray-600 mb-3">PDF mit 6-Minuten Taktung und Lücken für den Shooting-Tag.</p>
</div>
<button
onClick={() => handleGenerateAppointmentList(selectedJob)}
disabled={!selectedEventType || isListGenerating}
className="w-full px-3 py-2 bg-indigo-600 text-white text-xs font-bold rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-all flex justify-center items-center gap-2 mt-auto"
>
{isListGenerating ? 'Generiere...' : 'PDF Liste generieren'}
</button>
</div>
</div>
</div>
</div>
{/* Tool 3: Follow-up Emails */}
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:border-amber-300 transition-colors shadow-sm">
<div className="flex items-start justify-between mb-2">
<div className="p-2 bg-amber-50 rounded-lg text-amber-600 text-xl"></div>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">Demnächst</span>
</div>
<h5 className="font-bold text-gray-900 mb-1">Nachfass-Mails (Supermailer)</h5>
<p className="text-sm text-gray-500 mb-4 line-clamp-2">Analysiert das Kaufverhalten und generiert eine fertige CSV-Liste für den Supermailer.</p>
<button className="w-full px-4 py-2 bg-gray-100 text-gray-500 text-sm font-medium rounded-lg cursor-not-allowed">
Analyse starten (Dauert lange)
</button>
</div>
{/* Tool 4: Statistics */}
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:border-purple-300 transition-colors shadow-sm">
<div className="flex items-start justify-between mb-2">
<div className="p-2 bg-purple-50 rounded-lg text-purple-600 text-xl">📊</div>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">Aktiv</span>
</div>
<h5 className="font-bold text-gray-900 mb-1">Verkaufsstatistik</h5>
<p className="text-sm text-gray-500 mb-4 line-clamp-2">Durchforstet alle Alben und liefert eine Übersicht: Wie viele Kinder haben wie viel gekauft?</p>
{isStatsRunning ? (
<div className="w-full bg-gray-100 p-3 rounded-lg text-sm text-gray-700 flex flex-col gap-2">
<div className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4 text-purple-600" viewBox="0 0 24 24" fill="none"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /></svg>
<span className="font-medium text-purple-700">Läuft im Hintergrund...</span>
</div>
<p className="text-xs break-words">{statsProgress}</p>
</div>
) : (
<button
onClick={() => handleStartStatistics(selectedJob)}
disabled={processingJobId !== null || isStatsRunning}
className="w-full px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors shadow-sm"
>
Statistik-Lauf starten
</button>
)}
</div>
</div>
{/* Optional Data View Area (for later use, e.g., showing the stats table here) */}
{statsResult && (
<div className="mt-8 border-t border-gray-200 pt-6">
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-bold text-gray-800">Ergebnis der Auswertung</h4>
<span className="text-xs bg-emerald-100 text-emerald-800 px-2 py-1 rounded font-medium">Erfolgreich abgeschlossen</span>
</div>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm text-left">
<thead className="bg-gray-50 text-gray-500 uppercase">
<tr>
<th className="px-4 py-3 font-medium">Album</th>
<th className="px-4 py-3 font-medium text-center">Kinder (Gesamt)</th>
<th className="px-4 py-3 font-medium text-center">Mit Käufen</th>
<th className="px-4 py-3 font-medium text-center">Alle Bilder</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{statsResult.map((row, idx) => (
<tr key={idx} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{row.Album}</td>
<td className="px-4 py-3 text-center text-gray-600">{row.Kinder_insgesamt}</td>
<td className="px-4 py-3 text-center text-emerald-600 font-medium">{row.Kinder_mit_Käufen}</td>
<td className="px-4 py-3 text-center text-indigo-600 font-medium">{row.Kinder_Alle_Bilder_gekauft}</td>
</tr>
))}
{/* Total Row */}
<tr className="bg-gray-50 font-bold border-t-2 border-gray-300">
<td className="px-4 py-3 text-gray-900">Gesamt ({statsResult.length} Alben)</td>
<td className="px-4 py-3 text-center text-gray-900">
{statsResult.reduce((sum, row) => sum + (row.Kinder_insgesamt || 0), 0)}
</td>
<td className="px-4 py-3 text-center text-emerald-700">
{statsResult.reduce((sum, row) => sum + (row.Kinder_mit_Käufen || 0), 0)}
</td>
<td className="px-4 py-3 text-center text-indigo-700">
{statsResult.reduce((sum, row) => sum + (row.Kinder_Alle_Bilder_gekauft || 0), 0)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Modal Footer */}
<div className="bg-gray-100 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse border-t border-gray-200">
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={() => {
setSelectedJob(null);
setStatsResult(null);
setStatsTaskId(null);
}}
>
Schließen
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
export default App;