[32788f42] Add Termin-Übersicht feature, dynamic Event-Type selection, and refactor QR cards UI into Job Details
This commit is contained in:
@@ -173,6 +173,97 @@ def generate_pdf_from_csv(csv_path: str, institution: str, date_info: str, list_
|
|||||||
logger.info(f"Writing PDF to: {output_path}")
|
logger.info(f"Writing PDF to: {output_path}")
|
||||||
HTML(string=html_out).write_pdf(output_path)
|
HTML(string=html_out).write_pdf(output_path)
|
||||||
|
|
||||||
|
def generate_appointment_overview_pdf(raw_events: list, job_name: str, event_type_name: str, output_path: str):
|
||||||
|
from collections import defaultdict
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
parsed_events = []
|
||||||
|
for event in raw_events:
|
||||||
|
start_dt = datetime.datetime.fromisoformat(event['start_time'].replace('Z', '+00:00'))
|
||||||
|
start_dt = start_dt.astimezone(ZoneInfo("Europe/Berlin"))
|
||||||
|
|
||||||
|
num_children = ""
|
||||||
|
has_consent = False
|
||||||
|
for qa in event.get('questions_and_answers', []):
|
||||||
|
q_text = qa.get('question', '').lower()
|
||||||
|
a_text = qa.get('answer', '')
|
||||||
|
if "wie viele kinder" in q_text:
|
||||||
|
num_children = a_text
|
||||||
|
elif "schöne bilder" in q_text and "website veröffentlichen" in q_text:
|
||||||
|
if "ja, gerne" in a_text.lower():
|
||||||
|
has_consent = True
|
||||||
|
|
||||||
|
parsed_events.append({
|
||||||
|
"dt": start_dt,
|
||||||
|
"name": event['invitee_name'],
|
||||||
|
"children": num_children,
|
||||||
|
"consent": has_consent
|
||||||
|
})
|
||||||
|
|
||||||
|
grouped = defaultdict(list)
|
||||||
|
for e in parsed_events:
|
||||||
|
date_str = e['dt'].strftime("%d.%m.%Y")
|
||||||
|
grouped[date_str].append(e)
|
||||||
|
|
||||||
|
final_grouped = {}
|
||||||
|
for date_str, events in grouped.items():
|
||||||
|
events.sort(key=lambda x: x['dt'])
|
||||||
|
min_dt = events[0]['dt']
|
||||||
|
max_dt = events[-1]['dt']
|
||||||
|
|
||||||
|
slots = []
|
||||||
|
curr_dt = min_dt
|
||||||
|
event_idx = 0
|
||||||
|
|
||||||
|
while curr_dt <= max_dt or event_idx < len(events):
|
||||||
|
next_dt = curr_dt + datetime.timedelta(minutes=6)
|
||||||
|
|
||||||
|
events_in_slot = []
|
||||||
|
while event_idx < len(events) and events[event_idx]['dt'] < next_dt:
|
||||||
|
events_in_slot.append(events[event_idx])
|
||||||
|
event_idx += 1
|
||||||
|
|
||||||
|
if events_in_slot:
|
||||||
|
for e in events_in_slot:
|
||||||
|
slots.append({
|
||||||
|
"time_str": e['dt'].strftime("%H:%M"),
|
||||||
|
"name": e['name'],
|
||||||
|
"children": e['children'],
|
||||||
|
"consent": e['consent'],
|
||||||
|
"booked": True
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
if curr_dt <= max_dt:
|
||||||
|
slots.append({
|
||||||
|
"time_str": curr_dt.strftime("%H:%M"),
|
||||||
|
"name": "",
|
||||||
|
"children": "",
|
||||||
|
"consent": False,
|
||||||
|
"booked": False
|
||||||
|
})
|
||||||
|
|
||||||
|
curr_dt = next_dt
|
||||||
|
|
||||||
|
final_grouped[date_str] = slots
|
||||||
|
|
||||||
|
template_dir = os.path.join(os.path.dirname(__file__), "templates")
|
||||||
|
env = Environment(loader=FileSystemLoader(template_dir))
|
||||||
|
template = env.get_template("appointment_list.html")
|
||||||
|
|
||||||
|
current_time = datetime.datetime.now().strftime("%d.%m.%Y %H:%M Uhr")
|
||||||
|
logo_base64 = get_logo_base64()
|
||||||
|
|
||||||
|
render_context = {
|
||||||
|
"job_name": job_name,
|
||||||
|
"event_type_name": event_type_name or "Alle Events",
|
||||||
|
"current_time": current_time,
|
||||||
|
"logo_base64": logo_base64,
|
||||||
|
"grouped_slots": final_grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
html_out = template.render(render_context)
|
||||||
|
HTML(string=html_out).write_pdf(output_path)
|
||||||
|
|
||||||
# --- Selenium Scraper Functions ---
|
# --- Selenium Scraper Functions ---
|
||||||
|
|
||||||
def take_error_screenshot(driver, error_name):
|
def take_error_screenshot(driver, error_name):
|
||||||
@@ -410,10 +501,22 @@ 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
|
from qr_generator import get_calendly_events, overlay_text_on_pdf, get_calendly_event_types
|
||||||
|
|
||||||
# --- API Endpoints ---
|
# --- API Endpoints ---
|
||||||
|
|
||||||
|
@app.get("/api/calendly/event-types")
|
||||||
|
async def fetch_calendly_event_types():
|
||||||
|
api_token = os.getenv("CALENDLY_TOKEN")
|
||||||
|
if not api_token:
|
||||||
|
raise HTTPException(status_code=400, detail="Calendly API token missing.")
|
||||||
|
try:
|
||||||
|
types = get_calendly_event_types(api_token)
|
||||||
|
return {"event_types": types}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Calendly event types: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@app.get("/api/calendly/events")
|
@app.get("/api/calendly/events")
|
||||||
async def fetch_calendly_events(start_time: str, end_time: str, event_type_name: Optional[str] = None):
|
async def fetch_calendly_events(start_time: str, end_time: str, event_type_name: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
@@ -434,8 +537,8 @@ async def fetch_calendly_events(start_time: str, end_time: str, event_type_name:
|
|||||||
|
|
||||||
@app.post("/api/qr-cards/generate")
|
@app.post("/api/qr-cards/generate")
|
||||||
async def generate_qr_cards(
|
async def generate_qr_cards(
|
||||||
start_time: str = Form(...),
|
start_time: str = Form(None),
|
||||||
end_time: str = Form(...),
|
end_time: str = Form(None),
|
||||||
event_type_name: str = Form(None),
|
event_type_name: str = Form(None),
|
||||||
pdf_file: UploadFile = File(...)
|
pdf_file: UploadFile = File(...)
|
||||||
):
|
):
|
||||||
@@ -472,6 +575,40 @@ async def generate_qr_cards(
|
|||||||
logger.error(f"Error generating QR cards: {e}")
|
logger.error(f"Error generating QR cards: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.get("/api/jobs/{job_id}/appointment-list")
|
||||||
|
async def generate_appointment_list(job_id: str, event_type_name: str, db: Session = Depends(get_db)):
|
||||||
|
logger.info(f"API Request: Generate appointment list for job {job_id}, event_type '{event_type_name}'")
|
||||||
|
api_token = os.getenv("CALENDLY_TOKEN")
|
||||||
|
if not api_token:
|
||||||
|
raise HTTPException(status_code=400, detail="Calendly API token missing.")
|
||||||
|
|
||||||
|
# 1. Fetch job name from DB
|
||||||
|
job = db.query(DBJob).filter(DBJob.id == job_id).first()
|
||||||
|
job_name = job.name if job else f"Auftrag {job_id}"
|
||||||
|
|
||||||
|
# 2. Fetch raw Calendly events (no date range needed, defaults to +6 months)
|
||||||
|
try:
|
||||||
|
from qr_generator import get_calendly_events_raw
|
||||||
|
raw_events = get_calendly_events_raw(api_token, event_type_name=event_type_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching raw Calendly events: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
if not raw_events:
|
||||||
|
return JSONResponse(status_code=404, content={"message": "Keine passenden Termine für diesen Event-Typ gefunden."})
|
||||||
|
|
||||||
|
# 3. Generate PDF
|
||||||
|
temp_dir = tempfile.gettempdir()
|
||||||
|
output_name = f"Terminuebersicht_{job_id}_{datetime.datetime.now().strftime('%Y%m%d')}.pdf"
|
||||||
|
output_path = os.path.join(temp_dir, output_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
generate_appointment_overview_pdf(raw_events, job_name, event_type_name, output_path)
|
||||||
|
return FileResponse(path=output_path, filename=output_name, media_type="application/pdf")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating appointment overview pdf: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -11,7 +11,38 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger("qr-card-generator")
|
logger = logging.getLogger("qr-card-generator")
|
||||||
|
|
||||||
def get_calendly_events_raw(api_token: str, start_time: str, end_time: str, event_type_name: str = None):
|
def get_calendly_event_types(api_token: str):
|
||||||
|
"""
|
||||||
|
Fetches available event types for the current user.
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {api_token}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Get current user info
|
||||||
|
user_url = "https://api.calendly.com/users/me"
|
||||||
|
user_response = requests.get(user_url, headers=headers)
|
||||||
|
if not user_response.ok:
|
||||||
|
raise Exception(f"Calendly API Error: {user_response.status_code}")
|
||||||
|
|
||||||
|
user_data = user_response.json()
|
||||||
|
user_uri = user_data['resource']['uri']
|
||||||
|
|
||||||
|
# 2. Get event types
|
||||||
|
event_types_url = "https://api.calendly.com/event_types"
|
||||||
|
params = {
|
||||||
|
'user': user_uri
|
||||||
|
}
|
||||||
|
|
||||||
|
types_response = requests.get(event_types_url, headers=headers, params=params)
|
||||||
|
if not types_response.ok:
|
||||||
|
raise Exception(f"Calendly API Error: {types_response.status_code}")
|
||||||
|
|
||||||
|
types_data = types_response.json()
|
||||||
|
return types_data['collection']
|
||||||
|
|
||||||
|
def get_calendly_events_raw(api_token: str, start_time: str = None, end_time: str = None, event_type_name: str = None):
|
||||||
"""
|
"""
|
||||||
Debug function to fetch raw Calendly data without formatting.
|
Debug function to fetch raw Calendly data without formatting.
|
||||||
"""
|
"""
|
||||||
@@ -20,6 +51,12 @@ def get_calendly_events_raw(api_token: str, start_time: str, end_time: str, even
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Defaults: now to +180 days
|
||||||
|
if not start_time:
|
||||||
|
start_time = datetime.datetime.utcnow().isoformat() + "Z"
|
||||||
|
if not end_time:
|
||||||
|
end_time = (datetime.datetime.utcnow() + datetime.timedelta(days=180)).isoformat() + "Z"
|
||||||
|
|
||||||
# 1. Get current user info to get the user URI
|
# 1. Get current user info to get the user URI
|
||||||
user_url = "https://api.calendly.com/users/me"
|
user_url = "https://api.calendly.com/users/me"
|
||||||
user_response = requests.get(user_url, headers=headers)
|
user_response = requests.get(user_url, headers=headers)
|
||||||
@@ -75,7 +112,7 @@ def get_calendly_events_raw(api_token: str, start_time: str, end_time: str, even
|
|||||||
|
|
||||||
return raw_results
|
return raw_results
|
||||||
|
|
||||||
def get_calendly_events(api_token: str, start_time: str, end_time: str, event_type_name: str = None):
|
def get_calendly_events(api_token: str, start_time: str = None, end_time: str = None, event_type_name: str = None):
|
||||||
"""
|
"""
|
||||||
Fetches events from Calendly API for the current user within a time range.
|
Fetches events from Calendly API for the current user within a time range.
|
||||||
"""
|
"""
|
||||||
|
|||||||
156
fotograf-de-scraper/backend/templates/appointment_list.html
Normal file
156
fotograf-de-scraper/backend/templates/appointment_list.html
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Terminübersicht</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4 portrait;
|
||||||
|
margin: 20mm;
|
||||||
|
@bottom-right {
|
||||||
|
content: "Seite " counter(page) " von " counter(pages);
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 11pt;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-bottom: 2px solid #ddd;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.header-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.header-logo {
|
||||||
|
width: 150px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.header-logo img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 16pt;
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 14pt;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #34495e;
|
||||||
|
}
|
||||||
|
.date-header {
|
||||||
|
background-color: #ecf0f1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 13pt;
|
||||||
|
border-left: 4px solid #3498db;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
page-break-inside: auto;
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
page-break-after: auto;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #bdc3c7;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
.time-col { width: 12%; white-space: nowrap; }
|
||||||
|
.family-col { width: 35%; }
|
||||||
|
.children-col { width: 15%; text-align: center; }
|
||||||
|
.consent-col { width: 20%; text-align: center; }
|
||||||
|
.done-col { width: 18%; text-align: center; }
|
||||||
|
|
||||||
|
.empty-row td {
|
||||||
|
height: 35px; /* ensure enough space for writing */
|
||||||
|
color: transparent; /* visually hide "Empty" text but keep structure if any */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The checkbox square */
|
||||||
|
.checkbox-square {
|
||||||
|
display: inline-block;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
background-color: #fff;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-text">
|
||||||
|
<h1>{{ job_name }}</h1>
|
||||||
|
<h2>Terminübersicht ({{ event_type_name }})</h2>
|
||||||
|
<p>Stand: {{ current_time }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-logo">
|
||||||
|
{% if logo_base64 %}
|
||||||
|
<img src="data:image/png;base64,{{ logo_base64 }}" alt="Logo">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for date, slots in grouped_slots.items() %}
|
||||||
|
<div class="date-header">{{ date }}</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="time-col">Uhrzeit</th>
|
||||||
|
<th class="family-col">Familie</th>
|
||||||
|
<th class="children-col">Kinder</th>
|
||||||
|
<th class="consent-col">Veröffentlichung</th>
|
||||||
|
<th class="done-col">Erledigt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for slot in slots %}
|
||||||
|
<tr class="{% if not slot.booked %}empty-row{% endif %}">
|
||||||
|
<td class="time-col" style="color: #333;">{{ slot.time_str }}</td>
|
||||||
|
<td class="family-col">{{ slot.name if slot.booked else '' }}</td>
|
||||||
|
<td class="children-col">{{ slot.children if slot.booked else '' }}</td>
|
||||||
|
<td class="consent-col">
|
||||||
|
{% if slot.booked and slot.consent %}
|
||||||
|
<span style="font-size: 16pt;">☑</span>
|
||||||
|
{% elif slot.booked %}
|
||||||
|
<!-- nein -->
|
||||||
|
{% else %}
|
||||||
|
<!-- leer -->
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="done-col">
|
||||||
|
<span class="checkbox-square"></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endfor %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -33,13 +33,12 @@ function App() {
|
|||||||
const [isStatsRunning, setIsStatsRunning] = useState(false);
|
const [isStatsRunning, setIsStatsRunning] = useState(false);
|
||||||
|
|
||||||
// States for QR Generator
|
// 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 [isQrGenerating, setIsQrGenerating] = useState(false);
|
||||||
|
|
||||||
|
const [eventTypes, setEventTypes] = useState<any[]>([]);
|
||||||
|
const [selectedEventType, setSelectedEventType] = useState<string>("");
|
||||||
|
const [isListGenerating, setIsListGenerating] = useState(false);
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002';
|
||||||
|
|
||||||
const fetchJobs = async (account: AccountType, forceRefresh = false) => {
|
const fetchJobs = async (account: AccountType, forceRefresh = false) => {
|
||||||
@@ -68,6 +67,24 @@ function App() {
|
|||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
const handleRefresh = () => fetchJobs(activeTab, true);
|
const handleRefresh = () => fetchJobs(activeTab, true);
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchEventTypes = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/calendly/event-types`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setEventTypes(data.event_types || []);
|
||||||
|
if (data.event_types && data.event_types.length > 0) {
|
||||||
|
setSelectedEventType(data.event_types[0].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch event types:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchEventTypes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// Statistics Task Functions
|
// Statistics Task Functions
|
||||||
const handleStartStatistics = async (job: Job) => {
|
const handleStartStatistics = async (job: Job) => {
|
||||||
@@ -147,8 +164,9 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateQrCards = async () => {
|
|
||||||
if (!qrPdfFile) {
|
const handleGenerateQrCards = async (job: Job, file: File) => {
|
||||||
|
if (!file) {
|
||||||
setError("Bitte wähle eine PDF-Vorlage aus.");
|
setError("Bitte wähle eine PDF-Vorlage aus.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -157,10 +175,8 @@ function App() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('pdf_file', qrPdfFile);
|
formData.append('pdf_file', file);
|
||||||
formData.append('start_time', `${qrStartTime}T00:00:00Z`);
|
if (selectedEventType) formData.append('event_type_name', selectedEventType);
|
||||||
formData.append('end_time', `${qrEndTime}T23:59:59Z`);
|
|
||||||
if (qrEventType) formData.append('event_type_name', qrEventType);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/qr-cards/generate`, {
|
const response = await fetch(`${API_BASE_URL}/api/qr-cards/generate`, {
|
||||||
@@ -170,7 +186,7 @@ function App() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
throw new Error("Keine passenden Calendly-Termine in diesem Zeitraum gefunden.");
|
throw new Error("Keine passenden Calendly-Termine gefunden.");
|
||||||
}
|
}
|
||||||
const errData = await response.json();
|
const errData = await response.json();
|
||||||
throw new Error(errData.detail || 'Generierung fehlgeschlagen');
|
throw new Error(errData.detail || 'Generierung fehlgeschlagen');
|
||||||
@@ -180,13 +196,11 @@ function App() {
|
|||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `QR_Karten_Andruck_${qrStartTime}.pdf`;
|
a.download = `QR_Karten_Andruck_${job.id}.pdf`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
setIsQrModalOpen(false);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -194,6 +208,35 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateAppointmentList = async (job: Job) => {
|
||||||
|
setIsListGenerating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/appointment-list?event_type_name=${encodeURIComponent(selectedEventType)}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error("Keine passenden Calendly-Termine gefunden.");
|
||||||
|
}
|
||||||
|
const errData = await response.json();
|
||||||
|
throw new Error(errData.detail || 'PDF Generierung fehlgeschlagen');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `Terminuebersicht_${job.name.replace(/\s+/g, "_")}.pdf`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(`Listen-Fehler (${job.name}): ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsListGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const currentJobs = jobsCache[activeTab];
|
const currentJobs = jobsCache[activeTab];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -227,14 +270,7 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -411,20 +447,81 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tool 2: QR Cards */}
|
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:border-emerald-300 transition-colors shadow-sm">
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div className="p-2 bg-emerald-50 rounded-lg text-emerald-600 text-xl">📇</div>
|
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">Demnächst</span>
|
|
||||||
</div>
|
|
||||||
<h5 className="font-bold text-gray-900 mb-1">QR-Zugangskarten</h5>
|
|
||||||
<p className="text-sm text-gray-500 mb-4 line-clamp-2">Druckt Namen und Buchungszeiten auf vorbereitete PDF-Bögen für Familienfotos.</p>
|
|
||||||
<button className="w-full px-4 py-2 bg-gray-100 text-gray-500 text-sm font-medium rounded-lg cursor-not-allowed">
|
|
||||||
Karten-Generator öffnen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tool 3: Follow-up Emails */}
|
{/* Tool 2: Shooting-Planung (QR & List) */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:border-emerald-300 transition-colors shadow-sm md:col-span-2">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-emerald-50 rounded-lg text-emerald-600 text-xl">📆</div>
|
||||||
|
<h5 className="font-bold text-gray-900 text-lg">Shooting-Planung</h5>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-800">Neu</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Event Selection */}
|
||||||
|
<div className="md:col-span-1 border-r border-gray-100 pr-4">
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-2">Calendly Event auswählen</label>
|
||||||
|
<select
|
||||||
|
value={selectedEventType}
|
||||||
|
onChange={(e) => setSelectedEventType(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-sm"
|
||||||
|
>
|
||||||
|
{eventTypes.length === 0 && <option value="">Lade Events...</option>}
|
||||||
|
{eventTypes.map(et => (
|
||||||
|
<option key={et.uri} value={et.name}>{et.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">Die Termine für diesen Event-Typ werden aus Calendly importiert.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="md:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{/* Action 1: QR Cards */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<h6 className="font-bold text-sm text-gray-800 mb-1">📇 QR-Zugangskarten</h6>
|
||||||
|
<p className="text-xs text-gray-600 mb-3">Druckt Namen und Uhrzeit auf vorbereitete PDF-Bögen.</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
id={`qr-upload-${selectedJob.id}`}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
handleGenerateQrCards(selectedJob, e.target.files[0]);
|
||||||
|
e.target.value = ''; // reset
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => document.getElementById(`qr-upload-${selectedJob.id}`)?.click()}
|
||||||
|
disabled={!selectedEventType || isQrGenerating}
|
||||||
|
className="w-full px-3 py-2 bg-emerald-600 text-white text-xs font-bold rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-all flex justify-center items-center gap-2 mt-auto"
|
||||||
|
>
|
||||||
|
{isQrGenerating ? 'Generiere...' : 'Blanko PDF hochladen & starten'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action 2: Appointment List */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<h6 className="font-bold text-sm text-gray-800 mb-1">📄 Termin-Übersicht</h6>
|
||||||
|
<p className="text-xs text-gray-600 mb-3">PDF mit 6-Minuten Taktung und Lücken für den Shooting-Tag.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleGenerateAppointmentList(selectedJob)}
|
||||||
|
disabled={!selectedEventType || isListGenerating}
|
||||||
|
className="w-full px-3 py-2 bg-indigo-600 text-white text-xs font-bold rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-all flex justify-center items-center gap-2 mt-auto"
|
||||||
|
>
|
||||||
|
{isListGenerating ? 'Generiere...' : 'PDF Liste generieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Tool 3: Follow-up Emails */}
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:border-amber-300 transition-colors shadow-sm">
|
<div className="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="flex items-start justify-between mb-2">
|
||||||
<div className="p-2 bg-amber-50 rounded-lg text-amber-600 text-xl">✉️</div>
|
<div className="p-2 bg-amber-50 rounded-lg text-amber-600 text-xl">✉️</div>
|
||||||
@@ -535,104 +632,7 @@ 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user