[34288f42] Feature: Add 'Skip Calendly' option for siblings list generation

This commit is contained in:
2026-05-04 06:53:32 +00:00
parent db94eca626
commit 991e338d67
3 changed files with 507 additions and 70 deletions

View File

@@ -44,6 +44,7 @@ Detaillierte Analyse des Kaufverhaltens pro Gruppe/Klasse basierend auf den loka
### Feature 5: Geschwisterliste (Einrichtungsintern) (Vollständig)
Tool zur Identifizierung von Geschwistergruppen innerhalb einer Einrichtung inkl. Cross-Check mit Calendly-Buchungen und speziellen Geschwister-QR-Karten.
* **Flexibilität:** Optionaler Modus "Ohne Nachmittags-Shooting", um die Liste auch ohne Calendly-Abgleich (rein einrichtungsintern) zu generieren.
### Feature 6: Freigabeanfragen & Gutschein-Automation (Vollständig)
Vollautomatisierter DSGVO-Workflow zur Einholung von Veröffentlichungsgenehmigungen:

View File

@@ -10,13 +10,14 @@ from weasyprint import HTML
import tempfile
import shutil
import time
import json
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session
from database import get_db, Job as DBJob, engine, Base, JobParticipant, SessionLocal
from database import get_db, Job as DBJob, engine, Base, JobParticipant, SessionLocal, ReminderHistory
import math
import uuid
@@ -116,6 +117,8 @@ SELECTORS = {
"job_row_shooting_type": ".//td[count(//th[contains(., 'Typ')]/preceding-sibling::th) + 1]",
"export_dropdown": "[data-qa-id='dropdown:export']",
"export_csv_link": "button[data-qa-id='button:csv']",
# --- Reminder & Quick Login Selectors ---
"person_access_code_link": ".//a[contains(@data-qa-id, 'guest-access-banner-access-code')]",
# --- Statistics Selectors ---
"album_overview_rows": "//table/tbody/tr",
"album_overview_link": ".//td[2]//a",
@@ -535,7 +538,7 @@ def process_statistics(task_id: str, job_id: str, account_type: str):
finally:
db.close()
def process_reminder_analysis(task_id: str, job_id: str, account_type: str):
def process_reminder_analysis(task_id: str, job_id: str, account_type: str, max_logins: int = 1, exclude_purchased_emails: bool = True):
logger.info(f"Task {task_id}: Starting fast reminder analysis for job {job_id}")
task_store[task_id] = {"status": "running", "progress": "Analysiere Datenbank-Einträge...", "result": None}
@@ -547,12 +550,25 @@ def process_reminder_analysis(task_id: str, job_id: str, account_type: str):
task_store[task_id] = {"status": "error", "progress": "Keine Daten vorhanden. Bitte erst oben auf 'Daten abgleichen' klicken."}
return
# Query DB for potential candidates (Logins <= 1 and No Orders)
# 1. Get emails that have ALREADY purchased anything (in ANY job we have in DB)
purchased_emails = set()
if exclude_purchased_emails:
from sqlalchemy import or_
# We look globally across the whole job_participants table
purchased_results = db.query(JobParticipant.email_eltern).filter(
or_(JobParticipant.has_orders == 1, JobParticipant.digital_package_ordered == 1),
JobParticipant.email_eltern != "",
JobParticipant.email_eltern != None
).all()
purchased_emails = {r[0].lower() for r in purchased_results}
logger.info(f"Task {task_id}: Found {len(purchased_emails)} unique emails with existing purchases in DB to exclude.")
# 2. Query DB for potential candidates (Logins <= max_logins and No Orders)
candidates = db.query(JobParticipant).filter(
JobParticipant.job_id == job_id,
JobParticipant.has_orders == 0,
JobParticipant.logins <= 1,
JobParticipant.digital_package_ordered == 0,
JobParticipant.logins <= max_logins,
JobParticipant.email_eltern != "",
JobParticipant.email_eltern != None
).all()
@@ -560,15 +576,28 @@ def process_reminder_analysis(task_id: str, job_id: str, account_type: str):
if not candidates:
task_store[task_id] = {
"status": "completed",
"progress": "Keine passenden Empfänger (0-1 Logins, keine Bestellung) gefunden.",
"progress": f"Keine passenden Empfänger (0-{max_logins} Logins, keine Bestellung) gefunden.",
"result": []
}
return
# 3. Aggregate results by Email
aggregation = {}
missing_links_count = 0
for c in candidates:
email = c.email_eltern
email = c.email_eltern.lower()
# Skip if this email already has a purchase for ANOTHER child
if exclude_purchased_emails and email in purchased_emails:
continue
# STRICT LINK CHECK: If we don't have a scraped Quick Login URL, skip this child.
# We don't want to send broken /login/access/ links.
if not c.quick_login_url:
missing_links_count += 1
continue
if email not in aggregation:
aggregation[email] = {
"email": email,
@@ -583,9 +612,8 @@ def process_reminder_analysis(task_id: str, job_id: str, account_type: str):
if child_label and child_label not in aggregation[email]["children"]:
aggregation[email]["children"].append(child_label)
# Add Quick Login Link
link = f"https://www.kinderfotos-erding.de/a/{c.zugangscode}"
html_link = f'<a href="{link}">Fotos von {child_label}</a>'
# Add Quick Login Link (Guaranteed to exist here)
html_link = f'<a href="{c.quick_login_url}">Fotos von {child_label}</a>'
if html_link not in aggregation[email]["links"]:
aggregation[email]["links"].append(html_link)
@@ -603,9 +631,13 @@ def process_reminder_analysis(task_id: str, job_id: str, account_type: str):
"LinksHTML": links_html
})
progress_msg = f"Analyse fertig! {len(final_result)} Empfänger identifiziert."
if missing_links_count > 0:
progress_msg += f" (Hinweis: {missing_links_count} Kinder ignoriert, da Quick-Login-Link fehlt. Bitte vorher 'Daten abgleichen' drücken!)"
task_store[task_id] = {
"status": "completed",
"progress": f"Analyse fertig! {len(final_result)} Empfänger identifiziert.",
"progress": progress_msg,
"result": final_result
}
@@ -904,10 +936,16 @@ async def start_statistics(job_id: str, account_type: str, background_tasks: Bac
return {"task_id": task_id}
@app.post("/api/jobs/{job_id}/reminder-analysis")
async def start_reminder_analysis(job_id: str, account_type: str, background_tasks: BackgroundTasks):
logger.info(f"API Request: Start reminder analysis for job {job_id} ({account_type})")
async def start_reminder_analysis(
job_id: str,
account_type: str,
background_tasks: BackgroundTasks,
max_logins: int = 1,
exclude_purchased_emails: bool = True
):
logger.info(f"API Request: Start reminder analysis for job {job_id} ({account_type}, max_logins={max_logins}, exclude_purchased={exclude_purchased_emails})")
task_id = str(uuid.uuid4())
background_tasks.add_task(process_reminder_analysis, task_id, job_id, account_type)
background_tasks.add_task(process_reminder_analysis, task_id, job_id, account_type, max_logins, exclude_purchased_emails)
return {"task_id": task_id}
@app.get("/api/tasks/{task_id}/download-csv")
@@ -960,7 +998,7 @@ async def send_bulk_emails(request: BulkEmailRequest, db: Session = Depends(get_
"failed": failed_emails
}
def sync_participants(job_id: str, account_type: str, db: Session):
def sync_participants(job_id: str, account_type: str, db: Session, task_id: str = None):
logger.info(f"Syncing participants for job {job_id} ({account_type})")
username = os.getenv(f"{account_type.upper()}_USER")
password = os.getenv(f"{account_type.upper()}_PW")
@@ -972,6 +1010,7 @@ def sync_participants(job_id: str, account_type: str, db: Session):
raise Exception("Login failed.")
# Navigate to the Persons tab
if task_id: task_store[task_id]["progress"] = "Hole Teilnehmerliste (CSV)..."
job_url = f"https://app.fotograf.de/config_jobs_settings/index/{job_id}"
driver.get(job_url)
wait = WebDriverWait(driver, 30)
@@ -1028,6 +1067,8 @@ def sync_participants(job_id: str, account_type: str, db: Session):
"Klasse": "gruppe"
}
if task_id: task_store[task_id]["progress"] = "Aktualisiere Datenbank..."
# Upsert into database
for _, row in df.iterrows():
code = str(row.get("Zugangscode (1)", "")).strip()
@@ -1067,6 +1108,7 @@ def sync_participants(job_id: str, account_type: str, db: Session):
# --- PHASE 2: Scrape Orders for Digital Packages (Price Magic) ---
try:
if task_id: task_store[task_id]["progress"] = "Suche nach digitalen Käufen (Price Magic)..."
orders_url = f"https://app.fotograf.de/config_jobs_orders/{job_id}/customer_orders"
logger.info(f"Navigating to orders page for price magic: {orders_url}")
driver.get(orders_url)
@@ -1127,11 +1169,168 @@ def sync_participants(job_id: str, account_type: str, db: Session):
except Exception as order_err:
logger.error(f"Failed to scrape orders for price magic: {order_err}")
# --- PHASE 3: Link Magic (Scrape Quick Login URLs) ---
try:
# Find candidates for reminders who don't have a link yet
# We prioritize those with few logins and no orders
link_candidates = db.query(JobParticipant).filter(
JobParticipant.job_id == job_id,
JobParticipant.has_orders == 0,
JobParticipant.logins <= 5,
JobParticipant.quick_login_url == None
).all()
if link_candidates:
if task_id: task_store[task_id]["progress"] = f"Sammle Login-Links für {len(link_candidates)} Personen (Link Magic)..."
logger.info(f"Link Magic: Identified {len(link_candidates)} candidates for link scraping.")
# Navigate back to Persons tab
albums_overview_url = f"https://app.fotograf.de/config_jobs_photos/index/{job_id}"
logger.info(f"Navigating to Albums overview: {albums_overview_url}")
driver.get(albums_overview_url)
# Find all album links
album_elements = wait.until(EC.presence_of_all_elements_located((By.XPATH, SELECTORS["album_overview_link"])))
albums = [{"name": e.text, "url": e.get_attribute("href")} for e in album_elements]
codes_to_find = {c.zugangscode: c for c in link_candidates}
links_found = 0
for album in albums:
if not codes_to_find: break
logger.info(f"Searching for links in album: {album['name']}")
driver.get(album['url'])
try:
total_codes_text = wait.until(EC.visibility_of_element_located((By.XPATH, SELECTORS["access_code_count"]))).text
num_pages = math.ceil(int(total_codes_text) / 20)
for page_num in range(1, num_pages + 1):
if not codes_to_find: break
if page_num > 1:
driver.get(album['url'] + f"?page_guest_accesses={page_num}")
person_rows = wait.until(EC.presence_of_all_elements_located((By.XPATH, SELECTORS["person_rows"])))
# Map of codes on this page to their communication link
page_links = {}
for row in person_rows:
row_text = row.text
for code in list(codes_to_find.keys()):
if code in row_text:
try:
comm_link = row.find_element(By.XPATH, SELECTORS["person_access_code_link"]).get_attribute("href")
page_links[code] = comm_link
except: pass
# Now visit each communication page
for code, comm_link in page_links.items():
if code not in codes_to_find: continue
logger.debug(f"Scraping link for code {code}...")
if task_id: task_store[task_id]["progress"] = f"Hole Link {links_found+1} / {len(link_candidates)}..."
driver.get(comm_link)
try:
wait_short = WebDriverWait(driver, 5)
quick_link_el = wait_short.until(EC.presence_of_element_located((By.XPATH, SELECTORS["quick_login_url"])))
quick_link = quick_link_el.get_attribute("href")
# Update DB
codes_to_find[code].quick_login_url = quick_link
del codes_to_find[code]
links_found += 1
if links_found % 5 == 0: db.commit()
except:
logger.warning(f"Could not find quick login link for {code}")
# Go back to album page if we visited communication pages
if page_links:
driver.get(album['url'] + (f"?page_guest_accesses={page_num}" if page_num > 1 else ""))
wait.until(EC.presence_of_all_elements_located((By.XPATH, SELECTORS["person_rows"])))
except Exception as album_err:
logger.error(f"Error in album {album['name']}: {album_err}")
db.commit()
logger.info(f"Link Magic complete: Scraped {links_found} links.")
except Exception as link_err:
logger.error(f"Failed to scrape links: {link_err}")
return len(df)
finally:
driver.quit()
@app.get("/api/jobs/{job_id}/reminder-history")
async def get_reminder_history(job_id: str, db: Session = Depends(get_db)):
history = db.query(ReminderHistory).filter(ReminderHistory.job_id == job_id).order_by(ReminderHistory.timestamp.desc()).all()
return [
{
"id": h.id,
"timestamp": h.timestamp.isoformat(),
"recipient_count": h.recipient_count,
"max_logins": h.max_logins,
"scheduled_time": h.scheduled_time,
"recipients": json.loads(h.recipients_json) if h.recipients_json else []
}
for h in history
]
class SendReminderRequest(BaseModel):
emails: List[Dict[str, str]]
max_logins: int
scheduled_time: Optional[str] = None
recipients_data: List[Dict[str, Any]] # To store in history
@app.post("/api/jobs/{job_id}/reminder-send")
async def send_reminders(
job_id: str,
data: SendReminderRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db)
):
logger.info(f"Sending {len(data.emails)} reminders for job {job_id}")
# Save to history
new_history = ReminderHistory(
job_id=job_id,
recipient_count=len(data.emails),
max_logins=data.max_logins,
recipients_json=json.dumps(data.recipients_data),
scheduled_time=data.scheduled_time or "Sofort"
)
db.add(new_history)
db.commit()
# Reuse delayed_send logic from publish_request_api if scheduled
if data.scheduled_time:
from publish_request_api import delayed_send
from database import SessionLocal
background_tasks.add_task(delayed_send, data.emails, data.scheduled_time, SessionLocal)
return {"status": "scheduled", "message": f"Versand für {data.scheduled_time} geplant."}
# Immediate send
service = GmailService(db)
success = 0
failed = []
for email_data in data.emails:
if service.send_email(email_data["to"], email_data["subject"], email_data["body"]):
success += 1
else:
failed.append(email_data["to"])
return {"status": "success", "success": success, "failed": failed}
@app.get("/api/jobs/{job_id}/login-distribution")
async def get_login_distribution(job_id: str, db: Session = Depends(get_db)):
from sqlalchemy import func
results = db.query(
JobParticipant.logins,
func.count(JobParticipant.id)
).filter(JobParticipant.job_id == job_id).group_by(JobParticipant.logins).order_by(JobParticipant.logins).all()
return [{"logins": r[0], "count": r[1]} for r in results]
@app.get("/api/jobs/{job_id}/fast-stats")
async def get_fast_stats(job_id: str, db: Session = Depends(get_db)):
@@ -1180,14 +1379,28 @@ async def get_fast_stats(job_id: str, db: Session = Depends(get_db)):
statistics.sort(key=lambda x: x["Album"])
return statistics
@app.post("/api/jobs/{job_id}/sync-participants")
async def sync_participants_api(job_id: str, account_type: str, db: Session = Depends(get_db)):
def process_sync_task(task_id: str, job_id: str, account_type: str):
logger.info(f"Task {task_id}: Starting background sync for job {job_id}")
task_store[task_id] = {"status": "running", "progress": "Starte Synchronisierung...", "result": None}
db = SessionLocal()
try:
count = sync_participants(job_id, account_type, db)
return {"status": "success", "count": count}
count = sync_participants(job_id, account_type, db, task_id)
task_store[task_id] = {
"status": "completed",
"progress": f"Abgleich fertig! {count} Personen synchronisiert.",
"result": count
}
except Exception as e:
logger.exception("Sync failed")
raise HTTPException(status_code=500, detail=str(e))
logger.exception(f"Unexpected error in sync task {task_id}")
task_store[task_id] = {"status": "error", "progress": f"Fehler: {str(e)}"}
finally:
db.close()
@app.post("/api/jobs/{job_id}/sync-participants")
async def sync_participants_api(job_id: str, account_type: str, background_tasks: BackgroundTasks):
task_id = str(uuid.uuid4())
background_tasks.add_task(process_sync_task, task_id, job_id, account_type)
return {"task_id": task_id}
@app.get("/api/jobs/{job_id}/generate-pdf")
async def generate_pdf(job_id: str, account_type: str, db: Session = Depends(get_db)):
@@ -1297,23 +1510,24 @@ async def generate_pdf(job_id: str, account_type: str, db: Session = Depends(get
@app.get("/api/jobs/{job_id}/siblings-list")
async def generate_siblings_list(job_id: str, account_type: str, event_type_name: str = "", db: Session = Depends(get_db)):
logger.info(f"API Request: Generate siblings list for job {job_id}")
logger.info(f"API Request: Generate siblings list for job {job_id}, event_type: {event_type_name}")
username = os.getenv(f"{account_type.upper()}_USER")
password = os.getenv(f"{account_type.upper()}_PW")
api_token = os.getenv("CALENDLY_TOKEN")
if not api_token:
raise HTTPException(status_code=400, detail="Calendly API token missing.")
# Get Calendly events
from qr_generator import get_calendly_events_raw
try:
# Fetch ALL events to ensure we don't miss siblings due to event name mismatches
calendly_events = get_calendly_events_raw(api_token, event_type_name=None)
logger.info(f"Fetched {len(calendly_events)} total events from Calendly for siblings check.")
except Exception as e:
logger.error(f"Error fetching Calendly events: {e}")
calendly_events = []
calendly_events = []
if event_type_name:
if not api_token:
logger.warning("Calendly API token missing, skipping Calendly check.")
else:
# Get Calendly events
from qr_generator import get_calendly_events_raw
try:
# Fetch ALL events to ensure we don't miss siblings due to event name mismatches
calendly_events = get_calendly_events_raw(api_token, event_type_name=None)
logger.info(f"Fetched {len(calendly_events)} total events from Calendly for siblings check.")
except Exception as e:
logger.error(f"Error fetching Calendly events: {e}")
with tempfile.TemporaryDirectory() as temp_dir:
logger.debug(f"Using temp directory: {temp_dir}")

View File

@@ -37,15 +37,45 @@ function App() {
const [eventTypes, setEventTypes] = useState<any[]>([]);
const [selectedEventType, setSelectedEventType] = useState<string>("");
const [skipCalendly, setSkipCalendly] = useState(false);
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 [maxLogins, setMaxLogins] = useState<number>(1);
const [excludePurchased, setExcludePurchased] = useState<boolean>(true);
const [loginDistribution, setLoginDistribution] = useState<{logins: number, count: number}[] | null>(null);
const [latestFile, setLatestFile] = useState<any>(null);
const [isGmailAuthenticated, setIsGmailAuthenticated] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [syncTaskId, setSyncTaskId] = useState<string | null>(null);
const [syncProgress, setSyncProgress] = useState<string>('');
const fetchLoginDistribution = async (jobId: string) => {
try {
const response = await fetch(`${API_BASE_URL}/api/jobs/${jobId}/login-distribution`);
if (response.ok) {
const data = await response.json();
setLoginDistribution(data);
}
} catch (e) {
console.error("Failed to fetch login distribution", e);
}
};
const fetchReminderHistory = async (jobId: string) => {
try {
const response = await fetch(`${API_BASE_URL}/api/jobs/${jobId}/reminder-history`);
if (response.ok) {
const data = await response.json();
setReminderHistory(data);
}
} catch (e) {
console.error("Failed to fetch reminder history", e);
}
};
const fetchFastStats = async (jobId: string) => {
try {
@@ -64,33 +94,68 @@ function App() {
useEffect(() => {
if (selectedJob) {
fetchFastStats(selectedJob.id);
fetchLoginDistribution(selectedJob.id);
fetchReminderHistory(selectedJob.id);
}
}, [selectedJob]);
const handleSyncParticipants = async (job: Job) => {
setIsSyncing(true);
setSyncProgress('Starte Synchronisierung...');
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/sync-participants?account_type=${activeTab}`, {
method: 'POST'
});
if (response.ok) {
// alert("Daten erfolgreich mit Fotograf.de synchronisiert!");
fetchFastStats(job.id); // Refresh stats immediately
} else {
alert("Synchronisierung fehlgeschlagen.");
}
} catch (e) {
alert("Netzwerkfehler.");
if (!response.ok) throw new Error("Synchronisierung konnte nicht gestartet werden.");
const data = await response.json();
setSyncTaskId(data.task_id);
} catch (e: any) {
setError(e.message || "Netzwerkfehler.");
setIsSyncing(false);
}
setIsSyncing(false);
};
useEffect(() => {
let interval: ReturnType<typeof setInterval>;
if (syncTaskId) {
interval = setInterval(async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/tasks/${syncTaskId}`);
if (res.ok) {
const data = await res.json();
setSyncProgress(data.progress);
if (data.status === 'completed') {
setIsSyncing(false);
setSyncTaskId(null);
setSyncProgress(data.progress);
if (selectedJob) {
fetchFastStats(selectedJob.id);
fetchLoginDistribution(selectedJob.id);
}
} else if (data.status === 'error') {
setIsSyncing(false);
setSyncTaskId(null);
setError(data.progress || "Ein Fehler ist aufgetreten.");
}
}
} catch (err) {
console.error("Polling error", err);
}
}, 2000);
}
return () => clearInterval(interval);
}, [syncTaskId, selectedJob]);
// Email States
const [reminderResult, setReminderResult] = useState<any[] | null>(null);
const [reminderHistory, setReminderHistory] = 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);
const [reminderTab, setReminderTab] = useState<'config' | 'preview' | 'history'>('config');
const [reminderPreviewIndex, setReminderPreviewIndex] = useState(0);
// Release Request States
const [releaseEmails, setReleaseEmails] = useState("");
@@ -155,8 +220,6 @@ function App() {
}).filter(e => e.to);
}, [releaseEmails, selectedJob]);
const [reminderTab, setReminderTab] = useState<'config' | 'preview'>('config');
const [reminderPreviewIndex, setReminderPreviewIndex] = useState(0);
const [mainTab, setMainTab] = useState<'vorbereitung' | 'followup' | 'statistik'>('vorbereitung');
const parsedReminderEmails = useMemo(() => {
@@ -580,8 +643,24 @@ function App() {
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');
let url = `${API_BASE_URL}/api/jobs/${job.id}/siblings-list?account_type=${activeTab}`;
if (!skipCalendly && selectedEventType) {
url += `&event_type_name=${encodeURIComponent(selectedEventType)}`;
}
const response = await fetch(url);
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.detail || 'Generierung fehlgeschlagen');
}
const data = await response.json();
if (data.status === 'success' && data.download_url) {
window.open(`${API_BASE_URL}${data.download_url}`, '_blank');
} else {
throw new Error('Download URL konnte nicht vom Server abgerufen werden.');
}
setTimeout(() => {
setIsSiblingsGenerating(false);
fetchLatestFile();
@@ -612,10 +691,11 @@ function App() {
const handleStartReminderAnalysis = async (job: Job) => {
setIsReminderRunning(true);
setReminderProgress('Starte Analyse...');
setReminderResult(null);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/reminder-analysis?account_type=${activeTab}`, {
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/reminder-analysis?account_type=${activeTab}&max_logins=${maxLogins}&exclude_purchased_emails=${excludePurchased}`, {
method: 'POST'
});
if (!response.ok) throw new Error('Konnte Analyse nicht starten.');
@@ -637,7 +717,7 @@ function App() {
};
const handleSendEmails = async () => {
if (!reminderResult || !isGmailAuthenticated) return;
if (!reminderResult || !isGmailAuthenticated || !selectedJob) return;
setIsSendingEmails(true);
setEmailSendStatus("Sende...");
@@ -658,21 +738,25 @@ function App() {
});
try {
const response = await fetch(`${API_BASE_URL}/api/publish-request/send`, {
const response = await fetch(`${API_BASE_URL}/api/jobs/${selectedJob.id}/reminder-send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
emails: emailsToSend,
scheduled_time: scheduledTime || null
max_logins: maxLogins,
scheduled_time: scheduledTime || null,
recipients_data: reminderResult // Storing the full result row for history
})
});
if (response.ok) {
const data = await response.json();
setEmailSendStatus(`✅ Fertig! ${data.success} gesendet.`);
if (data.failed.length > 0) {
setEmailSendStatus(`✅ Fertig! ${data.success || 0} gesendet.`);
if (data.failed && data.failed.length > 0) {
setEmailSendStatus(prev => `${prev} (${data.failed.length} Fehler)`);
}
// Refresh history
fetchReminderHistory(selectedJob.id);
} else {
throw new Error("Sende-Fehler");
}
@@ -929,7 +1013,7 @@ function App() {
{isSyncing ? (
<>
<svg className="animate-spin h-3 w-3" 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>
Abgleich läuft...
{syncProgress || 'Abgleich läuft...'}
</>
) : (
<>🔄 Daten abgleichen</>
@@ -1085,6 +1169,19 @@ function App() {
<div>
<h6 className="font-bold text-sm text-gray-800 mb-1">👨👩👧👦 Geschwisterliste (Einrichtungsintern)</h6>
<p className="text-xs text-gray-600 mb-3">Abgleich von Kindergarten-Anmeldungen mit Calendly-Buchungen.</p>
<div className="flex items-center mb-3">
<input
type="checkbox"
id={`skip-calendly-${selectedJob.id}`}
checked={skipCalendly}
onChange={(e) => setSkipCalendly(e.target.checked)}
className="h-4 w-4 text-emerald-600 focus:ring-emerald-500 border-gray-300 rounded"
/>
<label htmlFor={`skip-calendly-${selectedJob.id}`} className="ml-2 block text-xs text-gray-700">
Ohne Nachmittags-Shooting (Kein Calendly-Abgleich)
</label>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-auto">
<button
@@ -1369,8 +1466,78 @@ function App() {
</div>
<div className="bg-gray-50 p-4 rounded-lg border border-gray-100 flex flex-col">
<h6 className="font-bold text-sm text-gray-800 mb-1">Erinnerungen (0-1 Logins)</h6>
<p className="text-xs text-gray-600 mb-4">Identifiziert Nicht-Käufer für den Supermailer oder Gmail Direkt-Versand.</p>
<h6 className="font-bold text-sm text-gray-800 mb-1">Erinnerungen (Konfigurierbar)</h6>
<p className="text-xs text-gray-600 mb-4">Identifiziert Nicht-Käufer basierend auf der Login-Anzahl.</p>
{/* Login Distribution Visualization */}
{loginDistribution && (
<div className="mb-6">
<label className="text-[10px] font-bold text-gray-400 uppercase mb-2 block">Login-Verteilung (Anzahl Personen)</label>
<div className="flex items-end gap-1 h-20 border-b border-gray-200 pb-1">
{loginDistribution.map((item, idx) => (
<div
key={idx}
className={`flex-1 flex flex-col justify-end items-center group relative ${item.logins <= maxLogins ? 'opacity-100' : 'opacity-30'}`}
>
<div
className={`w-full rounded-t-sm transition-all duration-300 ${item.logins <= maxLogins ? 'bg-amber-400' : 'bg-gray-300'}`}
style={{ height: `${Math.max(5, (item.count / Math.max(...loginDistribution.map(d => d.count))) * 100)}%` }}
></div>
<span className="text-[8px] text-gray-500 mt-1">{item.logins}</span>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 bg-gray-800 text-white text-[8px] py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none mb-1 whitespace-nowrap z-20">
{item.count} Personen mit {item.logins} Logins
</div>
</div>
))}
</div>
</div>
)}
<div className="space-y-4 mb-6 bg-white p-3 rounded-lg border border-gray-100 shadow-sm">
<div>
<div className="flex justify-between items-center mb-1">
<label className="text-xs font-bold text-gray-700">Max. Logins: {maxLogins}</label>
<span className="text-[10px] text-amber-600 font-bold">
{loginDistribution
? `Treffer: ${loginDistribution.filter(d => d.logins <= maxLogins).reduce((sum, d) => sum + d.count, 0)} Personen`
: ''}
</span>
</div>
<input
type="range"
min="0"
max="10"
value={maxLogins}
onChange={(e) => {
setMaxLogins(parseInt(e.target.value));
setReminderResult(null);
setReminderTaskId(null);
}}
className="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-amber-500"
/>
</div>
<label className="flex items-center gap-3 cursor-pointer group">
<div className="relative inline-flex items-center">
<input
type="checkbox"
className="sr-only peer"
checked={excludePurchased}
onChange={(e) => {
setExcludePurchased(e.target.checked);
setReminderResult(null);
setReminderTaskId(null);
}}
/>
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-amber-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-amber-500"></div>
</div>
<div className="flex flex-col">
<span className="text-xs font-bold text-gray-700">E-Mail bei Käufen ausschließen</span>
<span className="text-[10px] text-gray-500">Ignoriert die Mail, wenn bereits ein Kind gekauft hat.</span>
</div>
</label>
</div>
<div className="mt-auto">
{isReminderRunning ? (
@@ -1431,6 +1598,12 @@ function App() {
>
2. Vorschau & Versand
</button>
<button
onClick={() => setReminderTab('history')}
className={`px-4 py-2 text-xs font-bold ${reminderTab === 'history' ? 'text-indigo-600 border-b-2 border-indigo-600' : 'text-gray-500 hover:text-gray-700'}`}
>
3. Historie
</button>
</div>
{reminderTab === 'config' && (
@@ -1520,20 +1693,69 @@ function App() {
<>{parsedReminderEmails.length} Erinnerungs-Mails jetzt versenden</>
)}
</button>
{emailSendStatus && (
<p className="text-center text-xs font-bold text-indigo-600 mt-2">{emailSendStatus}</p>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
</>
)}
{emailSendStatus && (
<p className="text-center text-xs font-bold text-indigo-600 mt-2">{emailSendStatus}</p>
)}
</div>
)}
</div>
)}
{/* --- TAB: STATISTIK --- */} {mainTab === 'statistik' && (
{reminderTab === 'history' && (
<div className="space-y-4">
<div className="bg-white border border-gray-100 rounded-lg p-3 shadow-inner">
<div className="flex justify-between items-center mb-3">
<h6 className="text-[10px] font-bold text-gray-500 uppercase">Versand-Historie (Erinnerungen)</h6>
<button
onClick={() => fetchReminderHistory(selectedJob.id)}
className="text-[10px] bg-amber-50 text-amber-600 px-2 py-1 rounded hover:bg-amber-100 transition-colors flex items-center gap-1"
>
🔄 Aktualisieren
</button>
</div>
{!reminderHistory || reminderHistory.length === 0 ? (
<p className="text-[10px] text-gray-400 italic text-center py-4">Noch keine Versand-Aktivitäten für diesen Auftrag.</p>
) : (
<div className="max-h-80 overflow-y-auto rounded border border-gray-50">
<table className="min-w-full text-[10px] text-left">
<thead className="bg-gray-50 text-gray-400 sticky top-0">
<tr>
<th className="px-2 py-1">Datum/Zeit</th>
<th className="px-2 py-1">Empfänger</th>
<th className="px-2 py-1">Max Logins</th>
<th className="px-2 py-1">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{reminderHistory.map((h, idx) => (
<tr key={idx} className="hover:bg-amber-50/30 border-b border-gray-50">
<td className="px-2 py-2 text-gray-700">
{new Date(h.timestamp).toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'})}
</td>
<td className="px-2 py-2">
<div className="font-bold text-amber-600">{h.recipient_count} Personen</div>
<div className="text-[8px] text-gray-400 truncate max-w-[200px]">
{h.recipients.map((r: any) => r["Kindernamen"]).join(", ")}
</div>
</td>
<td className="px-2 py-2 text-gray-600">{h.max_logins}</td>
<td className="px-2 py-2 text-gray-400 italic">{h.scheduled_time || 'Sofort'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</>
)}
{/* --- TAB: STATISTIK --- */} {mainTab === 'statistik' && (
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:border-purple-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">