2 Commits

Author SHA1 Message Date
5e0186c534 [33e88f42] Keine Zusammenfassung angegeben.
Keine Zusammenfassung angegeben.
2026-04-10 21:51:12 +00:00
c2f614d7ad Docs: Aktualisierung der Dokumentation für Task [33e88f42] 2026-04-10 21:51:11 +00:00
9 changed files with 469 additions and 22 deletions

View File

@@ -1 +1 @@
{"task_id": "32788f42-8544-80e1-a13a-c26114cf9b34", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-04-08T16:39:28.516327"} {"task_id": "33e88f42-8544-80a2-8cee-e0b11287c523", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-04-10T21:51:09.122501"}

View File

@@ -1,6 +1,6 @@
# Fotograf.de Scraper & Management UI # Fotograf.de Scraper & Management UI
**Status:** Production-Ready Microservice (Core Feature: PDF List Generation, QR Cards & Shooting Schedule) **Status:** Production-Ready Microservice (Core Feature: PDF List Generation, QR Cards, Shooting Schedule & **Gmail API Integration**)
Dieser Service modernisiert die alten `Fotograf.de` Skripte, indem er eine robuste, web-basierte UI zur Verwaltung und Automatisierung von Foto-Aufträgen bereitstellt. Er ist als eigenständiger Microservice konzipiert, der unabhängig vom Haupt-Stack läuft. Dieser Service modernisiert die alten `Fotograf.de` Skripte, indem er eine robuste, web-basierte UI zur Verwaltung und Automatisierung von Foto-Aufträgen bereitstellt. Er ist als eigenständiger Microservice konzipiert, der unabhängig vom Haupt-Stack läuft.
@@ -10,13 +10,13 @@ Der Service besteht aus zwei Hauptkomponenten:
1. **Backend (Python / FastAPI / Selenium / SQLAlchemy):** 1. **Backend (Python / FastAPI / Selenium / SQLAlchemy):**
* **Automatisierung:** Nutzt Selenium für das Scraping von `fotograf.de`. * **Automatisierung:** Nutzt Selenium für das Scraping von `fotograf.de`.
* **Persistenz:** Eine SQLite-Datenbank (`fotograf_jobs.db`) speichert die Auftragsliste, sodass langsame Scraping-Vorgänge nur bei Bedarf (Refresh) nötig sind. * **Persistenz:** Eine SQLite-Datenbank (`fotograf_jobs.db`) speichert die Auftragsliste, sodass langsame Scraping-Vorgänge nur bei Bedarf (Refresh) nötig sind. Speichert außerdem OAuth-Tokens (`GmailToken`) für persistente E-Mail-Sitzungen.
* **PDF-Engine:** Nutzt WeasyPrint für Teilnehmerlisten und ReportLab/PyPDF2 für präzise PDF-Overlays (QR-Karten). * **PDF-Engine:** Nutzt WeasyPrint für Teilnehmerlisten und ReportLab/PyPDF2 für präzise PDF-Overlays (QR-Karten).
* **API-Integration:** Direkte Anbindung an die **Calendly API (v2)** zum Abruf von Live-Buchungsdaten. * **API-Integration:** Direkte Anbindung an die **Calendly API (v2)** zum Abruf von Live-Buchungsdaten sowie an die **Gmail API** für direkten E-Mail-Versand.
2. **Frontend (TypeScript / React / Vite / TailwindCSS):** 2. **Frontend (TypeScript / React / Vite / TailwindCSS):**
* **Modernes UI:** Ein vollständig responsives Dashboard mit Tailwind CSS (Kachel-Layout, Tabs für Kiga/Schule). * **Modernes UI:** Ein vollständig responsives Dashboard mit Tailwind CSS (Kachel-Layout, Tabs für Kiga/Schule).
* **Arbeitsfluss:** Tools sind direkt in der Detailansicht des jeweiligen Auftrags integriert (Shooting-Planung). * **Arbeitsfluss:** Tools sind direkt in der Detailansicht des jeweiligen Auftrags integriert (Shooting-Planung, E-Mail-Kampagnen).
## ✨ Core Features ## ✨ Core Features
@@ -36,16 +36,40 @@ Spezielles Modul für Familien-Mini-Shootings, direkt integriert in die Auftrags
* Generiert eine A4-Tabelle für den Shooting-Tag im 6-Minuten-Takt. * Generiert eine A4-Tabelle für den Shooting-Tag im 6-Minuten-Takt.
* Füllt Lücken für nicht gebuchte Slots automatisch leer auf. * Füllt Lücken für nicht gebuchte Slots automatisch leer auf.
### Feature 3: Nachfass-E-Mails (Vollständig) ### Feature 3: Nachfass-E-Mails & Gmail Direkt-Versand (Vollständig)
* Identifizierung von Käufern/Nicht-Käufern zur Generierung von CSV-Listen für den Supermailer. Identifizierung von potenziellen Käufern und automatisierter Kontakt.
* **Analyse-Logik:** Sucht nach Personen mit 0-1 Logins, die noch keine Bilder gekauft haben.
* **Supermailer Export:** Generierung einer fertigen CSV-Liste.
* **Direkter Gmail-Versand (Neu):**
* Volle OAuth 2.0 Integration. Einmaliger Login, das Refresh-Token hält die Sitzung aktiv.
* Inline-Editor für Betreff und HTML-Nachricht mit Live-Platzhaltern (`{Name Käufer}`, `{Kindernamen}`, `{LinksHTML}`).
* Massensendungs-API (Bulk Send) schickt Mails direkt über das verbundene Postfach.
### Feature 4: Verkaufs-Statistiken (Vollständig) ### Feature 4: Verkaufs-Statistiken (Vollständig)
* Detaillierte Analyse des Kaufverhaltens pro Album mit Echtzeit-Fortschrittsanzeige im Browser. * Detaillierte Analyse des Kaufverhaltens pro Album mit Echtzeit-Fortschrittsanzeige im Browser.
---
## 🎯 Nächste Session: "Freigabeanfragen" (Feature 5)
Das nächste große Ziel ist der automatische Versand von Freigabeanfragen via Gmail.
**Der geplante Workflow (Bestätigt):**
Anstatt Bild für Bild auf `fotograf.de` zu prüfen, nutzen wir die bereits erteilte **Einwilligung aus Calendly**.
* **Logik:** Das System prüft die Calendly-Buchungen des Shootings auf die Frage *"Dürfen wir einige schöne Aufnahmen veröffentlichen?"*.
* Alle Kunden, die hier "Ja" (bzw. eine positive Antwort) angegeben haben, werden extrahiert und erhalten automatisiert über die Gmail-API die Freigabeanfrage.
* *Text & Template werden in der nächsten Sitzung definiert.*
---
## 🛠️ Technische Details & Fixes (April 2026) ## 🛠️ Technische Details & Fixes (April 2026)
* **E-Mail Signatur:** Die offizielle HTML-Signatur von "Kinderfotos Erding" (inkl. Logo via Google Drive Link) ist hartcodiert hinterlegt und wird bei jedem Massen- oder Testversand unsichtbar im Hintergrund an den HTML-Body angehängt.
* **Gmail OAuth Architektur:** Strikt getrennt. App-Credentials (`client_id`, `client_secret`) liegen in der `.env`. User-Credentials (Access & Refresh Tokens) werden dynamisch in der SQLite-Datenbank (`GmailToken`) gespeichert.
* **Routing / Reverse Proxy:**
* Das Frontend (React/Vite) nutzt den `base: '/fotograf-de/'` Pfad, um Assets korrekt hinter dem Nginx-Proxy zu laden.
* API-Aufrufe nutzen relative Pfade (`/fotograf-de-api/`), welche von Nginx sauber zum internen Backend-Port `8000` umgeschrieben (`rewrite`) werden. Dies verhindert Mixed-Content und CORS-Fehler im Produktionsbetrieb.
* **Zeitzonen:** Automatische Konversion von Calendly-UTC-Zeiten in die lokale Zeit (`Europe/Berlin`). * **Zeitzonen:** Automatische Konversion von Calendly-UTC-Zeiten in die lokale Zeit (`Europe/Berlin`).
* **Pagination Fix:** Das Backend blättert durch alle Calendly-Seiten für lückenlose Daten. * **Pagination Fix:** Das Backend blättert durch alle Calendly-Seiten für lückenlose Daten.
* **Logo-Integration:** Dynamisches Einbetten des Firmenlogos in alle generierten Dokumente.
## 🚀 Deployment & Konfiguration ## 🚀 Deployment & Konfiguration
@@ -55,8 +79,9 @@ Der Service wird über eine eigene `docker-compose.yml` im Unterverzeichnis gest
Folgende Variablen müssen in der `.env` im Verzeichnis `/fotograf-de-scraper/` definiert sein: Folgende Variablen müssen in der `.env` im Verzeichnis `/fotograf-de-scraper/` definiert sein:
* `KIGA_USER` / `KIGA_PW` / `SCHULE_USER` / `SCHULE_PW`: Logins für Fotograf.de. * `KIGA_USER` / `KIGA_PW` / `SCHULE_USER` / `SCHULE_PW`: Logins für Fotograf.de.
* `CALENDLY_TOKEN`: Personal Access Token (JWT) von Calendly. * `CALENDLY_TOKEN`: Personal Access Token (JWT) von Calendly.
* `google_fotograf_client_id` / `google_fotograf_secret`: Die OAuth-App-Credentials aus der Google Cloud Console.
* `GOOGLE_REDIRECT_URI`: (Optional) Standard ist `https://floke-ai.duckdns.org/fotograf-de-api/api/auth/callback`.
### URLs & Ports ### URLs & Ports
* **Scraper Frontend:** `http://192.168.178.6:3009` * **Produktion / Nginx:** `https://floke-ai.duckdns.org/fotograf-de/`
* **Zentrales Dashboard:** `http://192.168.178.6:8090` * **Persistenz:** Datenbank unter `./backend/data/fotograf_jobs.db`.
* **Persistenz:** Datenbank unter `./backend/data/fotograf_jobs.db`.

View File

@@ -22,6 +22,12 @@ class Job(Base):
account_type = Column(String, index=True) # 'kiga' or 'schule' account_type = Column(String, index=True) # 'kiga' or 'schule'
last_updated = Column(DateTime, default=datetime.datetime.utcnow) last_updated = Column(DateTime, default=datetime.datetime.utcnow)
class GmailToken(Base):
__tablename__ = "gmail_tokens"
id = Column(Integer, primary_key=True)
token_json = Column(String) # Stores the full credentials JSON
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
def get_db(): def get_db():

View File

@@ -0,0 +1,129 @@
import os
import json
import logging
import datetime
from typing import Optional, List, Dict, Any
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from googleapiclient.discovery import build
from google.auth.transport.requests import Request
from sqlalchemy.orm import Session
from database import GmailToken
import base64
from email.mime.text import MIMEText
logger = logging.getLogger("gmail-service")
# Scopes required for sending emails
SCOPES = ['https://www.googleapis.com/auth/gmail.send']
class GmailService:
def __init__(self, db: Session):
self.db = db
self.client_id = os.getenv("google_fotograf_client_id")
self.client_secret = os.getenv("google_fotograf_secret")
# Redirect URI - must match what was configured in Google Console
# We try to detect the public URL, fallback to duckdns
self.redirect_uri = os.getenv("GOOGLE_REDIRECT_URI", "https://floke-ai.duckdns.org/fotograf-de-api/api/auth/callback")
def _get_client_config(self) -> Dict[str, Any]:
return {
"web": {
"client_id": self.client_id,
"project_id": "fotograf-tool",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_secret": self.client_secret,
"redirect_uris": [self.redirect_uri]
}
}
def get_auth_url(self) -> str:
flow = Flow.from_client_config(
self._get_client_config(),
scopes=SCOPES,
redirect_uri=self.redirect_uri
)
auth_url, _ = flow.authorization_url(prompt='consent', access_type='offline')
return auth_url
def handle_callback(self, code: str):
flow = Flow.from_client_config(
self._get_client_config(),
scopes=SCOPES,
redirect_uri=self.redirect_uri
)
flow.fetch_token(code=code)
credentials = flow.credentials
self._save_token(credentials)
return credentials
def _save_token(self, credentials):
token_data = {
'token': credentials.token,
'refresh_token': credentials.refresh_token,
'token_uri': credentials.token_uri,
'client_id': credentials.client_id,
'client_secret': credentials.client_secret,
'scopes': credentials.scopes
}
db_token = self.db.query(GmailToken).first()
if not db_token:
db_token = GmailToken(token_json=json.dumps(token_data))
self.db.add(db_token)
else:
db_token.token_json = json.dumps(token_data)
self.db.commit()
logger.info("Gmail OAuth token saved to database.")
def get_credentials(self) -> Optional[Credentials]:
db_token = self.db.query(GmailToken).first()
if not db_token:
return None
token_data = json.loads(db_token.token_json)
creds = Credentials.from_authorized_user_info(token_data, SCOPES)
if creds and creds.expired and creds.refresh_token:
logger.info("Gmail token expired, refreshing...")
creds.refresh(Request())
self._save_token(creds)
return creds
def is_authenticated(self) -> bool:
try:
creds = self.get_credentials()
return creds is not None and creds.valid
except Exception as e:
logger.error(f"Auth check failed: {e}")
return False
def send_email(self, to: str, subject: str, body_html: str) -> bool:
creds = self.get_credentials()
if not creds:
logger.error("Cannot send email: Not authenticated.")
return False
try:
service = build('gmail', 'v1', credentials=creds)
message = MIMEText(body_html, 'html')
message['to'] = to
message['subject'] = subject
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
send_result = service.users().messages().send(
userId='me',
body={'raw': raw_message}
).execute()
logger.info(f"Email sent to {to}. Message ID: {send_result['id']}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to}: {e}")
return False

View File

@@ -746,14 +746,16 @@ def process_reminder_analysis(task_id: str, job_id: str, account_type: str):
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks, UploadFile, File, Form from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db, Job as DBJob, engine, Base from database import get_db, Job as DBJob, engine, Base
import math import math
import uuid import uuid
from qr_generator import get_calendly_events, overlay_text_on_pdf, get_calendly_event_types from qr_generator import get_calendly_events, overlay_text_on_pdf, get_calendly_event_types
from gmail_service import GmailService
# --- API Endpoints --- # --- API Endpoints ---
@@ -914,6 +916,31 @@ async def download_latest_file():
async def health_check(): async def health_check():
return {"status": "ok"} return {"status": "ok"}
# --- Gmail API Endpoints ---
@app.get("/api/auth/google")
async def get_google_auth_url(db: Session = Depends(get_db)):
service = GmailService(db)
return {"url": service.get_auth_url()}
@app.get("/api/auth/callback")
async def google_auth_callback(code: str, db: Session = Depends(get_db)):
service = GmailService(db)
try:
service.handle_callback(code)
# Redirect back to frontend
# The frontend lives at /fotograf-de/ through NGINX
frontend_url = os.getenv("FRONTEND_URL", "https://floke-ai.duckdns.org/fotograf-de/")
return RedirectResponse(url=frontend_url)
except Exception as e:
logger.error(f"Auth callback failed: {e}")
return JSONResponse(status_code=500, content={"message": f"Authentifizierung fehlgeschlagen: {str(e)}"})
@app.get("/api/gmail/status")
async def get_gmail_status(db: Session = Depends(get_db)):
service = GmailService(db)
return {"authenticated": service.is_authenticated()}
@app.get("/api/jobs", response_model=List[Dict[str, Any]]) @app.get("/api/jobs", response_model=List[Dict[str, Any]])
async def get_jobs(account_type: str, force_refresh: bool = False, db: Session = Depends(get_db)): async def get_jobs(account_type: str, force_refresh: bool = False, db: Session = Depends(get_db)):
logger.info(f"API Request: GET /api/jobs for {account_type} (force_refresh={force_refresh})") logger.info(f"API Request: GET /api/jobs for {account_type} (force_refresh={force_refresh})")
@@ -1034,6 +1061,34 @@ async def download_task_csv(task_id: str):
logger.error(f"Export error: {e}") logger.error(f"Export error: {e}")
raise HTTPException(status_code=500, detail="CSV Export fehlgeschlagen.") raise HTTPException(status_code=500, detail="CSV Export fehlgeschlagen.")
class BulkEmailRequest(BaseModel):
emails: List[Dict[str, str]]
@app.post("/api/gmail/send-bulk")
async def send_bulk_emails(request: BulkEmailRequest, db: Session = Depends(get_db)):
service = GmailService(db)
if not service.is_authenticated():
raise HTTPException(status_code=401, detail="Gmail nicht authentifiziert.")
success_count = 0
failed_emails = []
for email_data in request.emails:
to = email_data.get("to")
subject = email_data.get("subject")
body = email_data.get("body")
if service.send_email(to, subject, body):
success_count += 1
else:
failed_emails.append(to)
return {
"total": len(request.emails),
"success": success_count,
"failed": failed_emails
}
@app.get("/api/jobs/{job_id}/generate-pdf") @app.get("/api/jobs/{job_id}/generate-pdf")
async def generate_pdf(job_id: str, account_type: str, db: Session = Depends(get_db)): async def generate_pdf(job_id: str, account_type: str, db: Session = Depends(get_db)):
logger.info(f"API Request: Generate PDF for job {job_id} ({account_type})") logger.info(f"API Request: Generate PDF for job {job_id} ({account_type})")

View File

@@ -12,3 +12,6 @@ requests==2.31.0
reportlab==4.0.9 reportlab==4.0.9
PyPDF2==3.0.1 PyPDF2==3.0.1
tzdata tzdata
google-api-python-client==2.122.0
google-auth-httplib2==0.2.0
google-auth-oauthlib==1.2.0

View File

@@ -42,8 +42,64 @@ function App() {
const [reminderProgress, setReminderProgress] = useState<string>(''); const [reminderProgress, setReminderProgress] = useState<string>('');
const [isReminderRunning, setIsReminderRunning] = useState(false); const [isReminderRunning, setIsReminderRunning] = useState(false);
const [latestFile, setLatestFile] = useState<any>(null); 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);
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002'; // 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 () => { const fetchLatestFile = async () => {
try { try {
@@ -83,6 +139,7 @@ function App() {
fetchJobs(activeTab, false); fetchJobs(activeTab, false);
} }
fetchLatestFile(); fetchLatestFile();
checkGmailAuth();
}, [activeTab]); }, [activeTab]);
const handleRefresh = () => fetchJobs(activeTab, true); const handleRefresh = () => fetchJobs(activeTab, true);
@@ -157,6 +214,18 @@ function App() {
}; };
}, [statsTaskId, isStatsRunning]); }, [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(() => { useEffect(() => {
let interval: any; let interval: any;
if (reminderTaskId && isReminderRunning) { if (reminderTaskId && isReminderRunning) {
@@ -168,8 +237,7 @@ function App() {
setReminderProgress(data.progress || 'Verarbeite...'); setReminderProgress(data.progress || 'Verarbeite...');
if (data.status === 'completed') { if (data.status === 'completed') {
setIsReminderRunning(false); setIsReminderRunning(false);
// Auto-trigger download or show button? The user wants a CSV. handleFetchReminderResult(reminderTaskId);
// Let's keep the task ID so we can show a download button.
} else if (data.status === 'error') { } else if (data.status === 'error') {
setError(data.progress || 'Ein Fehler ist aufgetreten.'); setError(data.progress || 'Ein Fehler ist aufgetreten.');
setIsReminderRunning(false); setIsReminderRunning(false);
@@ -296,6 +364,78 @@ function App() {
setError("Download fehlgeschlagen."); 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/gmail/send-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emails: emailsToSend })
});
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]; const currentJobs = jobsCache[activeTab];
return ( return (
@@ -317,6 +457,28 @@ function App() {
Zum Dashboard Zum Dashboard
</a> </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 && ( {latestFile && (
<div className="hidden lg:flex items-center gap-2"> <div className="hidden lg:flex items-center gap-2">
<span className="text-xs text-gray-400">Letzte Datei:</span> <span className="text-xs text-gray-400">Letzte Datei:</span>
@@ -623,13 +785,70 @@ function App() {
<p className="text-xs break-words">{reminderProgress}</p> <p className="text-xs break-words">{reminderProgress}</p>
</div> </div>
) : reminderTaskId ? ( ) : reminderTaskId ? (
<button <div className="space-y-3">
onClick={() => handleDownloadReminderCsv(reminderTaskId)} <button
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" 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 herunterladen <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>
</button> 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 <button
onClick={() => handleStartReminderAnalysis(selectedJob)} onClick={() => handleStartReminderAnalysis(selectedJob)}

View File

@@ -4,4 +4,5 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
base: '/fotograf-de/', // Ensures assets are loaded with the correct prefix behind NGINX
}) })

View File

@@ -259,3 +259,12 @@ Investierte Zeit in dieser Session: 08:17
Arbeitszusammenfassung: Arbeitszusammenfassung:
Keine Zusammenfassung angegeben. Keine Zusammenfassung angegeben.
``` ```
## 🤖 Status-Update (2026-04-10 23:51 Berlin Time)
```yaml
Investierte Zeit in dieser Session: 01:12
Arbeitszusammenfassung:
Keine Zusammenfassung angegeben.
```