[32788f42] feat: implement database persistence, modernized UI with Tailwind, and Calendly-integrated QR card generator for Fotograf.de scraper
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import './App.css';
|
||||
|
||||
interface Job {
|
||||
@@ -8,6 +8,7 @@ interface Job {
|
||||
status: string;
|
||||
date: string;
|
||||
shooting_type: string;
|
||||
last_updated?: string;
|
||||
}
|
||||
|
||||
type AccountType = 'kiga' | 'schule';
|
||||
@@ -25,13 +26,27 @@ function App() {
|
||||
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 [isQrModalOpen, setIsQrModalOpen] = useState(false);
|
||||
const [qrStartTime, setQrStartTime] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [qrEndTime, setQrEndTime] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [qrEventType, setQrEventType] = useState('Familie');
|
||||
const [qrPdfFile, setQrPdfFile] = useState<File | null>(null);
|
||||
const [isQrGenerating, setIsQrGenerating] = useState(false);
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002';
|
||||
|
||||
const fetchJobs = async (account: AccountType) => {
|
||||
const fetchJobs = async (account: AccountType, forceRefresh = false) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/jobs?account_type=${account}`);
|
||||
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');
|
||||
@@ -45,7 +60,66 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => fetchJobs(activeTab);
|
||||
useEffect(() => {
|
||||
// Only fetch if we haven't already fetched it
|
||||
if (jobsCache[activeTab] === null) {
|
||||
fetchJobs(activeTab, false);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const handleRefresh = () => fetchJobs(activeTab, true);
|
||||
|
||||
// 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);
|
||||
@@ -73,6 +147,53 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateQrCards = async () => {
|
||||
if (!qrPdfFile) {
|
||||
setError("Bitte wähle eine PDF-Vorlage aus.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsQrGenerating(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pdf_file', qrPdfFile);
|
||||
formData.append('start_time', `${qrStartTime}T00:00:00Z`);
|
||||
formData.append('end_time', `${qrEndTime}T23:59:59Z`);
|
||||
if (qrEventType) formData.append('event_type_name', qrEventType);
|
||||
|
||||
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 in diesem Zeitraum 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_${qrStartTime}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
setIsQrModalOpen(false);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsQrGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentJobs = jobsCache[activeTab];
|
||||
|
||||
return (
|
||||
@@ -105,6 +226,15 @@ function App() {
|
||||
Schule
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setIsQrModalOpen(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-emerald-50 text-emerald-700 text-sm font-semibold rounded-lg hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
📇 QR-Karten Tool
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -274,7 +404,7 @@ function App() {
|
||||
<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}
|
||||
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'}
|
||||
@@ -311,22 +441,77 @@ function App() {
|
||||
<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-gray-100 text-gray-600">Demnächst</span>
|
||||
<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>
|
||||
<button className="w-full px-4 py-2 bg-gray-100 text-gray-500 text-sm font-medium rounded-lg cursor-not-allowed">
|
||||
Statistik-Lauf starten
|
||||
</button>
|
||||
|
||||
{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) */}
|
||||
<div className="mt-8 border-t border-gray-200 pt-6 hidden">
|
||||
<h4 className="text-lg font-bold text-gray-800 mb-4">Ergebnisse</h4>
|
||||
{/* Table or graphs will go here */}
|
||||
</div>
|
||||
{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>
|
||||
|
||||
@@ -335,7 +520,11 @@ function App() {
|
||||
<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)}
|
||||
onClick={() => {
|
||||
setSelectedJob(null);
|
||||
setStatsResult(null);
|
||||
setStatsTaskId(null);
|
||||
}}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
@@ -345,6 +534,105 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- QR Generator Modal --- */}
|
||||
{isQrModalOpen && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto" 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">
|
||||
<div className="fixed inset-0 bg-gray-900 bg-opacity-75 transition-opacity" onClick={() => setIsQrModalOpen(false)}></div>
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen">​</span>
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full">
|
||||
<div className="bg-white px-6 py-6 border-b border-gray-100 flex justify-between items-center">
|
||||
<h3 className="text-xl font-bold text-gray-900">QR-Karten Generator</h3>
|
||||
<button onClick={() => setIsQrModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-100 text-red-700 text-sm rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">Von Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={qrStartTime}
|
||||
onChange={(e) => setQrStartTime(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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">Bis Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={qrEndTime}
|
||||
onChange={(e) => setQrEndTime(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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">Event-Typ Filter (Calendly Name)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. Familie"
|
||||
value={qrEventType}
|
||||
onChange={(e) => setQrEventType(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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">Blanko PDF-Vorlage (mit QR-Codes)</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => setQrPdfFile(e.target.files?.[0] || null)}
|
||||
className="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-emerald-50 file:text-emerald-700 hover:file:bg-emerald-100 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-emerald-50 p-4 rounded-xl">
|
||||
<h4 className="text-sm font-bold text-emerald-800 mb-1">Info</h4>
|
||||
<p className="text-xs text-emerald-700 leading-relaxed">
|
||||
Das Tool lädt alle passenden Termine aus deinem Calendly-Account und druckt Name, Personenanzahl und Uhrzeit exakt auf die hochgeladene Vorlage (2 Karten pro Seite).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-6 py-4 flex flex-row-reverse gap-3">
|
||||
<button
|
||||
onClick={handleGenerateQrCards}
|
||||
disabled={isQrGenerating || !qrPdfFile}
|
||||
className="px-6 py-2 bg-emerald-600 text-white text-sm font-bold rounded-lg hover:bg-emerald-700 focus:ring-4 focus:ring-emerald-100 disabled:opacity-50 transition-all flex items-center gap-2"
|
||||
>
|
||||
{isQrGenerating ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 text-white" 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>
|
||||
Generiere PDF...
|
||||
</>
|
||||
) : 'PDF jetzt generieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsQrModalOpen(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user