Files
Brancheneinstufung2/fotograf-de-scraper/frontend/src/App.tsx
Floke 1f5805e64c [34588f42] Feat: Versandzeit-Steuerung für Freigabe-Anfragen hinzugefügt
- Backend unterstützt nun zeitgesteuerten Versand (scheduled_time) via BackgroundTasks.
- Frontend um ein Zeitauswahl-Feld erweitert.
2026-04-17 20:21:44 +00:00

1252 lines
61 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 [isSiblingsGenerating, setIsSiblingsGenerating] = useState(false);
const [isSiblingsQrGenerating, setIsSiblingsQrGenerating] = useState(false);
const [reminderTaskId, setReminderTaskId] = useState<string | null>(null);
const [reminderProgress, setReminderProgress] = useState<string>('');
const [isReminderRunning, setIsReminderRunning] = useState(false);
const [latestFile, setLatestFile] = useState<any>(null);
const [isGmailAuthenticated, setIsGmailAuthenticated] = useState(false);
// Email States
const [reminderResult, setReminderResult] = useState<any[] | null>(null);
const [emailSubject, setEmailSubject] = useState("Fotos von {Kindernamen}");
const [emailBody, setEmailBody] = useState("Hallo {Name Käufer},<br><br>deine Fotos sind fertig und warten auf dich! Klicke einfach auf die Links unten, um direkt zu den Galerien zu gelangen:<br><br>{LinksHTML}<br><br>Viel Spaß beim Anschauen!");
const [isSendingEmails, setIsSendingEmails] = useState(false);
const [emailSendStatus, setEmailSendStatus] = useState<string | null>(null);
// Release Request States
const [releaseEmails, setReleaseEmails] = useState("");
const [releaseCodes, setReleaseCodes] = useState("");
const [releaseStats, setReleaseStats] = useState<any>(null);
const [isUploadingCodes, setIsUploadingCodes] = useState(false);
const [uploadMessage, setUploadMessage] = useState("");
const [isSendingRelease, setIsSendingRelease] = useState(false);
const [releaseMessage, setReleaseMessage] = useState("");
const [scheduledTime, setScheduledTime] = useState(""); // New state
const fetchReleaseStats = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/publish-request/stats`);
if (response.ok) {
const data = await response.json();
setReleaseStats(data);
}
} catch (e) {
console.error("Failed to fetch release stats", e);
}
};
const handleUploadCodes = async () => {
setIsUploadingCodes(true);
setUploadMessage("Lädt hoch...");
try {
const response = await fetch(`${API_BASE_URL}/api/publish-request/codes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ codes: releaseCodes })
});
const data = await response.json();
if (response.ok) {
setUploadMessage(`${data.added} neue Codes gespeichert.`);
setReleaseCodes("");
fetchReleaseStats();
} else {
setUploadMessage("❌ Fehler beim Hochladen.");
}
} catch (e) {
setUploadMessage("❌ Netzwerkfehler.");
}
setIsUploadingCodes(false);
};
const handleSendRelease = async () => {
if (!reminderResult || !isGmailAuthenticated) return;
setIsSendingRelease(true);
setReleaseMessage("Bereite Senden vor...");
const targetEmails = releaseEmails.split(',').map(e => e.trim().toLowerCase()).filter(e => e);
// We only send to emails that are in targetEmails and exist in reminderResult
const targetRows = reminderResult.filter(row => {
const rowEmail = row["E-Mail-Adresse Käufer"]?.trim().toLowerCase();
return targetEmails.includes(rowEmail);
});
if (targetRows.length === 0) {
setReleaseMessage("⚠️ Keine passenden E-Mails im Auftrag gefunden (bitte vorher Supermailer-Analyse starten).");
setIsSendingRelease(false);
return;
}
const emailsToSend = targetRows.map(row => {
// Split the buyer name to get first name
const fullName = row["Name Käufer"] || "";
const firstName = fullName.split(' ')[0] || "Liebe Eltern";
const kindergartenName = selectedJob ? selectedJob.name.replace(/\(JOB\d+\)\s*/, '') : "dem Kindergarten";
const childrenNames = row["Kindernamen"] || "Euren Kindern";
let subject = "Eure Bilder vom Kindergarten-Fotoshooting";
let body = `Guten Morgen ${firstName},<br><br>vielen Dank für Eure Teilnahme am Mini-Familien-Fotoshooting im Kindergarten ${kindergartenName} diese Woche. Die Bilder sind jetzt bereits online, ihr solltet bald eine Mail dazu erhalten. :)<br><br>Die Bilder von ${childrenNames} gefallen uns sehr gut, sie wirken auf den Bildern sehr selbstbewusst. Gerne würden wir diese in unserer Galerie auf www.kinderfotos-erding.de (Link: <a href="https://www.kinderfotos-erding.de/angebot/schulfotograf/#5134aec7-5498-49ab-b26e-73574510c90a">Beispiel ansehen</a>) veröffentlichen.<br><br>Um den rechtlichen Anforderungen (DSGVO) gerecht zu werden, müsstet Ihr noch dieses Formular auf unserer Website ausfüllen:<br><a href="https://www.kinderfotos-erding.de/angebot/schulfotograf/freigabe-zur-veroeffentlichung/"><b>Zum Formular zur Veröffentlichung</b></a><br><br>Das hilft uns wirklich sehr, damit andere einen besseren Eindruck von unserer Arbeit gewinnen.<br><br>Als kleines Dankeschön erhaltet Ihr im Anschluss einen Rabattcode über 25 € für Eure Bestellung. Diesen senden wir Euch per separater E-Mail zu, sobald das Formular ausgefüllt ist. Bitte wartet mit Eurer Bestellung, bis wir Euch den Rabattcode zugesendet haben.<br><br>Vielen Dank für Eure Unterstützung und Euer Vertrauen!<br><br>` + SIGNATURE_HTML;
return {
to: row["E-Mail-Adresse Käufer"],
subject: subject,
body: body
};
});
setReleaseMessage(`Sende ${emailsToSend.length} Mails...`);
try {
const response = await fetch(`${API_BASE_URL}/api/publish-request/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
emails: emailsToSend,
scheduled_time: scheduledTime || null
})
});
const data = await response.json();
if (response.ok) {
setReleaseMessage(`✅ Fertig! ${data.success} gesendet. ${data.failed.length > 0 ? '(' + data.failed.length + ' Fehler)' : ''}`);
} else {
setReleaseMessage("❌ Fehler beim Senden.");
}
} catch (e) {
setReleaseMessage("❌ Netzwerkfehler.");
}
setIsSendingRelease(false);
};
// Email Signature (Cleaned up from user input)
const SIGNATURE_HTML = `
<br><br>
<span style="color: #888;">--</span><br>
<div dir="ltr">
<table border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse; margin-top: 5px;">
<tbody>
<tr>
<td width="220" valign="top" style="padding-right: 15px;">
<img width="200" src="https://lh3.googleusercontent.com/d/1K7RODOqKE2e1nRJ3D4dEWdjthoTMyXUq" alt="Kinderfotos Erding Logo" style="display: block;">
</td>
<td valign="bottom" style="padding-left: 15px; border-left: 1px solid #ddd; font-family: sans-serif; font-size: 13px; color: #333; line-height: 1.5;">
<p style="margin: 0;"><b>Kinderfotos Erding</b> | <a href="http://www.kinderfotos-erding.de/" target="_blank" style="color: #1155cc; text-decoration: none;">www.kinderfotos-erding.de</a></p>
<p style="margin: 0; color: #666;">Gartenstr. 10 | 85445 Oberding | 08122-8470867</p>
</td>
</tr>
</tbody>
</table>
</div>
`;
// If we are on duckdns, use the relative path, otherwise use local IP
const API_BASE_URL = window.location.hostname.includes('duckdns.org')
? '/fotograf-de-api'
: (import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002');
const checkGmailAuth = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/gmail/status`);
if (response.ok) {
const data = await response.json();
setIsGmailAuthenticated(data.authenticated);
}
} catch (err) {
console.error("Failed to check Gmail auth status");
}
};
const handleGmailLogin = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/auth/google`);
if (response.ok) {
const data = await response.json();
window.location.href = data.url;
}
} catch (err) {
setError("Konnte Gmail-Anmeldung nicht starten.");
}
};
const fetchLatestFile = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/jobs/latest-file`);
if (response.ok) {
const data = await response.json();
if (data.has_file) {
setLatestFile(data);
}
}
} catch (err) {
console.error("Failed to fetch latest file info");
}
};
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);
}
fetchLatestFile();
checkGmailAuth();
}, [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) {
const savedType = localStorage.getItem('fotograf_selected_event_type');
const typeExists = data.event_types.some((et: any) => et.name === savedType);
if (savedType && typeExists) {
setSelectedEventType(savedType);
} else {
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 handleFetchReminderResult = async (taskId: string) => {
try {
const res = await fetch(`${API_BASE_URL}/api/tasks/${taskId}`);
if (res.ok) {
const data = await res.json();
setReminderResult(data.result);
}
} catch (err) {
console.error("Failed to fetch reminder results");
}
};
useEffect(() => {
let interval: any;
if (reminderTaskId && isReminderRunning) {
interval = setInterval(async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/tasks/${reminderTaskId}`);
if (!res.ok) throw new Error('Task Status Request failed');
const data = await res.json();
setReminderProgress(data.progress || 'Verarbeite...');
if (data.status === 'completed') {
setIsReminderRunning(false);
handleFetchReminderResult(reminderTaskId);
} else if (data.status === 'error') {
setError(data.progress || 'Ein Fehler ist aufgetreten.');
setIsReminderRunning(false);
setReminderTaskId(null);
}
} catch (err: any) {
console.error("Polling Error:", err);
}
}, 1000);
}
return () => { if (interval) clearInterval(interval); };
}, [reminderTaskId, isReminderRunning]);
const handleGeneratePdf = async (job: Job) => {
setProcessingJobId(job.id);
setError(null);
try {
// Direkter Download über die URL, umgeht das Blob/Mixed-Content-Sicherheitsproblem in Chrome.
const downloadUrl = `${API_BASE_URL}/api/jobs/${job.id}/generate-pdf?account_type=${activeTab}`;
window.open(downloadUrl, '_blank');
// Wir setzen einen künstlichen Timeout für den Lade-Indikator,
// da wir bei window.open nicht wissen, wann der Download fertig ist.
setTimeout(() => {
setProcessingJobId(null);
fetchLatestFile();
}, 3000);
} catch (err: any) {
setError(`PDF Fehler (${job.name}): ${err.message}`);
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();
// Delay removal and revocation to ensure download starts in all browsers
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
fetchLatestFile();
}, 100);
} catch (err: any) {
setError(err.message);
} finally {
setIsQrGenerating(false);
}
};
const handleGenerateSiblingsQrCards = async (job: Job, file: File) => {
if (!file) {
setError("Bitte wähle eine PDF-Vorlage aus.");
return;
}
setIsSiblingsQrGenerating(true);
setError(null);
const formData = new FormData();
formData.append('pdf_file', file);
try {
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/siblings-qr-cards?account_type=${activeTab}`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
if (response.status === 404) {
throw new Error("Keine Geschwisterkinder für QR-Karten gefunden.");
}
const errData = await response.json();
throw new Error(errData.detail || 'Generierung fehlgeschlagen');
}
const result = await response.json();
if (result.status === "success" && result.download_url) {
window.open(`${API_BASE_URL}${result.download_url}`, '_blank');
setTimeout(fetchLatestFile, 2000);
} else {
throw new Error("Download URL could not be retrieved from server.");
}
} catch (err: any) {
setError(err.message);
} finally {
setIsSiblingsQrGenerating(false);
}
};
const handleGenerateSiblingsList = async (job: Job) => {
setIsSiblingsGenerating(true);
setError(null);
try {
const downloadUrl = `${API_BASE_URL}/api/jobs/${job.id}/siblings-list?account_type=${activeTab}&event_type_name=${encodeURIComponent(selectedEventType)}`;
window.open(downloadUrl, '_blank');
setTimeout(() => {
setIsSiblingsGenerating(false);
fetchLatestFile();
}, 3000);
} catch (err: any) {
setError(`Geschwisterlisten-Fehler (${job.name}): ${err.message}`);
setIsSiblingsGenerating(false);
}
};
const handleGenerateAppointmentList = async (job: Job) => {
setIsListGenerating(true);
setError(null);
try {
const downloadUrl = `${API_BASE_URL}/api/jobs/${job.id}/appointment-list?event_type_name=${encodeURIComponent(selectedEventType)}`;
window.open(downloadUrl, '_blank');
setTimeout(() => {
setIsListGenerating(false);
fetchLatestFile();
}, 3000);
} catch (err: any) {
setError(`Listen-Fehler (${job.name}): ${err.message}`);
setIsListGenerating(false);
}
};
const handleStartReminderAnalysis = async (job: Job) => {
setIsReminderRunning(true);
setReminderProgress('Starte Analyse...');
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/reminder-analysis?account_type=${activeTab}`, {
method: 'POST'
});
if (!response.ok) throw new Error('Konnte Analyse nicht starten.');
const data = await response.json();
setReminderTaskId(data.task_id);
} catch (err: any) {
setError(err.message);
setIsReminderRunning(false);
}
};
const handleDownloadReminderCsv = async (taskId: string) => {
try {
window.open(`${API_BASE_URL}/api/tasks/${taskId}/download-csv`, '_blank');
setTimeout(fetchLatestFile, 2000);
} catch (err: any) {
setError("Download fehlgeschlagen.");
}
};
const handleSendEmails = async () => {
if (!reminderResult || !isGmailAuthenticated) return;
setIsSendingEmails(true);
setEmailSendStatus("Sende...");
const emailsToSend = reminderResult.map(row => {
let subject = emailSubject.replace(/{Kindernamen}/g, row["Kindernamen"]);
let body = emailBody
.replace(/{Name Käufer}/g, row["Name Käufer"])
.replace(/{Kindernamen}/g, row["Kindernamen"])
.replace(/{LinksHTML}/g, row["LinksHTML"])
.replace(/\n/g, "<br>");
return {
to: row["E-Mail-Adresse Käufer"],
subject: subject,
body: body + SIGNATURE_HTML
};
});
try {
const response = await fetch(`${API_BASE_URL}/api/publish-request/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
emails: emailsToSend,
scheduled_time: scheduledTime || null
})
});
if (response.ok) {
const data = await response.json();
setEmailSendStatus(`✅ Fertig! ${data.success} gesendet.`);
if (data.failed.length > 0) {
setEmailSendStatus(prev => `${prev} (${data.failed.length} Fehler)`);
}
} else {
throw new Error("Sende-Fehler");
}
} catch (err) {
setEmailSendStatus("❌ Fehler beim Senden");
} finally {
setIsSendingEmails(false);
}
};
const handleSendTestEmail = async () => {
if (!isGmailAuthenticated) return;
setIsSendingEmails(true);
try {
const response = await fetch(`${API_BASE_URL}/api/gmail/send-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
emails: [{
to: "floke.com@gmail.com",
subject: "Test-E-Mail vom Fotograf Tool (inkl. Signatur)",
body: "Hallo! Das ist eine Test-E-Mail, um die Gmail-API-Integration zu verifizieren. Wenn du das liest, funktioniert alles perfekt! 🚀" + SIGNATURE_HTML
}]
})
});
if (response.ok) {
alert("Test-E-Mail erfolgreich an floke.com@gmail.com gesendet!");
} else {
alert("Fehler beim Senden der Test-E-Mail.");
}
} catch (err) {
alert("Netzwerkfehler beim Senden der Test-E-Mail.");
} finally {
setIsSendingEmails(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-6">
<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>
<a
href="http://192.168.178.6:8090"
className="hidden sm:flex text-xs font-medium text-gray-500 hover:text-indigo-600 transition-colors items-center gap-1 bg-gray-50 px-2 py-1 rounded border border-gray-200"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
Zum Dashboard
</a>
<button
onClick={handleGmailLogin}
className={`flex text-xs font-medium transition-colors items-center gap-1 px-2 py-1 rounded border ${
isGmailAuthenticated
? 'bg-emerald-50 text-emerald-600 border-emerald-200'
: 'bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100'
}`}
>
<span className="text-lg">{isGmailAuthenticated ? '✅' : '✉️'}</span>
{isGmailAuthenticated ? 'Gmail verbunden' : 'Gmail verbinden'}
</button>
{isGmailAuthenticated && (
<button
onClick={handleSendTestEmail}
disabled={isSendingEmails}
className="text-[10px] bg-white border border-gray-200 text-gray-400 hover:text-indigo-600 hover:border-indigo-200 px-2 py-1 rounded transition-all"
>
{isSendingEmails ? 'Sende...' : 'Test-Mail senden'}
</button>
)}
{latestFile && (
<div className="hidden lg:flex items-center gap-2">
<span className="text-xs text-gray-400">Letzte Datei:</span>
<a
href={`${API_BASE_URL}/api/jobs/download-latest`}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-bold text-emerald-600 hover:text-emerald-700 underline flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
{latestFile.display_name} ({latestFile.timestamp})
</a>
</div>
)}
</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);
localStorage.setItem('fotograf_selected_event_type', 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>
{/* Action 3: Siblings List */}
<div className="bg-gray-50 p-4 rounded-lg flex flex-col justify-between sm:col-span-2">
<div>
<h6 className="font-bold text-sm text-gray-800 mb-1">👨👩👧👦 Geschwisterliste (Einrichtungsintern)</h6>
<p className="text-xs text-gray-600 mb-3">Sucht nach Geschwisterkindern in der Einrichtung und gleicht diese mit Calendly ab.</p>
</div>
<div className="grid grid-cols-2 gap-3 mt-auto">
<button
onClick={() => handleGenerateSiblingsList(selectedJob)}
disabled={isSiblingsGenerating}
className="w-full px-3 py-2 bg-teal-600 text-white text-xs font-bold rounded-lg hover:bg-teal-700 disabled:opacity-50 transition-all flex justify-center items-center gap-2"
>
{isSiblingsGenerating ? 'Generiere...' : '📄 PDF Liste'}
</button>
<div>
<input
type="file"
accept=".pdf"
id={`siblings-qr-upload-${selectedJob.id}`}
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
handleGenerateSiblingsQrCards(selectedJob, e.target.files[0]);
e.target.value = ''; // reset
}
}}
/>
<button
onClick={() => document.getElementById(`siblings-qr-upload-${selectedJob.id}`)?.click()}
disabled={isSiblingsQrGenerating}
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 h-full"
>
{isSiblingsQrGenerating ? 'Generiere...' : '📇 QR-Karten drucken'}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Tool 4: Freigabe-Anfrage */}
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:border-amber-300 transition-colors shadow-sm mt-4">
<div className="flex items-start justify-between mb-2">
<div className="p-2 bg-indigo-50 rounded-lg text-indigo-600 text-xl">🖼</div>
</div>
<h5 className="font-bold text-gray-900 mb-1">Anfrage Veröffentlichung</h5>
<p className="text-sm text-gray-500 mb-4 line-clamp-2">Sende personalisierte DSGVO-Anfragen für ausgewählte Eltern inkl. Gutschein-Webhook.</p>
<div className="space-y-4">
{/* Upload Codes */}
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<div className="flex justify-between items-center mb-2">
<label className="text-[10px] font-bold text-gray-500 uppercase">Gutscheincodes hinzufügen</label>
{releaseStats && (
<span className="text-xs text-indigo-600 font-bold">Verfügbar: {releaseStats.available} (Verwendet: {releaseStats.used})</span>
)}
</div>
<textarea
placeholder="z.B. CODE1, CODE2, CODE3"
value={releaseCodes}
onChange={(e) => setReleaseCodes(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 mb-2 h-16"
/>
<button
onClick={handleUploadCodes}
disabled={isUploadingCodes || !releaseCodes.trim()}
className="w-full px-3 py-1.5 bg-indigo-100 text-indigo-700 text-xs font-bold rounded-lg hover:bg-indigo-200 disabled:opacity-50"
>
{isUploadingCodes ? 'Lädt hoch...' : 'Codes speichern'}
</button>
{uploadMessage && <p className="text-center text-xs mt-2 font-bold text-gray-600">{uploadMessage}</p>}
</div>
{/* Send Requests */}
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<label className="text-[10px] font-bold text-gray-500 uppercase block mb-2">Ziel-E-Mails für Anfrage</label>
<p className="text-[10px] text-gray-400 mb-2 leading-tight">
Die Daten (Namen) werden automatisch aus der Supermailer-Analyse gezogen. Bitte die Analyse oben ('CSV für Supermailer') vorher durchlaufen lassen, damit alle Käuferdaten bereitliegen.
</p>
<textarea
placeholder="z.B. max@muster.de, anna@test.de"
value={releaseEmails}
onChange={(e) => setReleaseEmails(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 mb-2 h-20"
/>
<div className="flex items-center gap-2 mb-3 bg-white p-2 rounded-lg border border-gray-100">
<span className="text-[10px] font-bold text-gray-400 uppercase">Versandzeit (Optional)</span>
<input
type="time"
value={scheduledTime}
onChange={(e) => setScheduledTime(e.target.value)}
className="text-xs border border-gray-200 rounded px-2 py-1 focus:ring-1 focus:ring-indigo-500"
/>
<span className="text-[10px] text-gray-400 italic">Leer = sofort</span>
</div>
<button
onClick={handleSendRelease}
disabled={isSendingRelease || !releaseEmails.trim() || !reminderResult || !isGmailAuthenticated}
className="w-full px-3 py-1.5 bg-indigo-600 text-white text-sm font-bold rounded-lg hover:bg-indigo-700 disabled:opacity-50 shadow-sm"
>
{isSendingRelease ? 'Sende...' : 'Anfrage-E-Mails jetzt senden'}
</button>
{!reminderResult && <p className="text-center text-[10px] mt-2 text-red-500">Bitte erst Tool 3 Analyse starten.</p>}
{!isGmailAuthenticated && <p className="text-center text-[10px] mt-2 text-red-500">Gmail nicht verbunden.</p>}
{releaseMessage && <p className="text-center text-xs mt-2 font-bold text-indigo-600">{releaseMessage}</p>}
</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-amber-100 text-amber-700">Aktiv</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>
{isReminderRunning ? (
<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-amber-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-amber-700">Analyse läuft...</span>
</div>
<p className="text-xs break-words">{reminderProgress}</p>
</div>
) : reminderTaskId ? (
<div className="space-y-3">
<button
onClick={() => handleDownloadReminderCsv(reminderTaskId)}
className="w-full px-4 py-2 bg-emerald-600 text-white text-sm font-medium rounded-lg hover:bg-emerald-700 transition-colors shadow-sm flex items-center justify-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
CSV für Supermailer
</button>
{reminderResult && isGmailAuthenticated && (
<div className="mt-4 border-t border-gray-100 pt-4 bg-gray-50 p-4 rounded-xl space-y-4">
<h6 className="font-bold text-gray-900 flex items-center gap-2">
<span>🚀</span> Gmail Direkt-Versand
</h6>
<p className="text-xs text-gray-500">
{reminderResult.length} Empfänger identifiziert.
</p>
<div className="space-y-2">
<label className="text-[10px] font-bold text-gray-400 uppercase">Betreff</label>
<input
value={emailSubject}
onChange={(e) => setEmailSubject(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-bold text-gray-400 uppercase">Nachricht (HTML erlaubt)</label>
<textarea
value={emailBody}
onChange={(e) => setEmailBody(e.target.value)}
rows={4}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 font-mono"
/>
<div className="flex justify-between items-center text-[10px] text-gray-400">
<span>Platzhalter: {"{Name Käufer}"}, {"{Kindernamen}"}, {"{LinksHTML}"}</span>
<span className="flex items-center gap-1">
<svg className="w-3 h-3 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Signatur "Kinderfotos Erding" wird automatisch angehängt
</span>
</div>
</div>
<button
onClick={handleSendEmails}
disabled={isSendingEmails}
className="w-full py-2.5 bg-indigo-600 text-white text-sm font-bold rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-md flex items-center justify-center gap-2"
>
{isSendingEmails ? (
<>
<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>
Sende Mails...
</>
) : (
<>Mails jetzt versenden</>
)}
</button>
{emailSendStatus && (
<p className="text-center text-xs font-bold text-indigo-600">{emailSendStatus}</p>
)}
</div>
)}
</div>
) : (
<button
onClick={() => handleStartReminderAnalysis(selectedJob)}
disabled={processingJobId !== null || isReminderRunning || isStatsRunning}
className="w-full px-4 py-2 bg-amber-600 text-white text-sm font-medium rounded-lg hover:bg-amber-700 disabled:opacity-50 transition-colors shadow-sm"
>
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;