Add event preview with two-step download flow
This commit is contained in:
54
web/app.py
54
web/app.py
@@ -11,7 +11,7 @@ import os
|
||||
import secrets
|
||||
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.background import BackgroundTask
|
||||
@@ -55,6 +55,58 @@ def index(request: Request, _: None = Depends(require_auth)):
|
||||
)
|
||||
|
||||
|
||||
@app.post("/preview")
|
||||
async def preview_pdf(
|
||||
file: UploadFile = File(...),
|
||||
_: None = Depends(require_auth),
|
||||
):
|
||||
filename = file.filename or "dienstplan.pdf"
|
||||
if not filename.lower().endswith(".pdf"):
|
||||
return JSONResponse(
|
||||
{"error": "Bitte eine PDF-Datei hochladen."},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
data = await file.read()
|
||||
if not data:
|
||||
return JSONResponse(
|
||||
{"error": "Die Datei ist leer."},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="pdf_to_ics_web_preview_") as temp_dir:
|
||||
temp_dir_path = Path(temp_dir)
|
||||
pdf_path = temp_dir_path / "upload.pdf"
|
||||
pdf_path.write_bytes(data)
|
||||
|
||||
dienstplan = extract_dienstplan_data(str(pdf_path))
|
||||
if not dienstplan.get("events"):
|
||||
return JSONResponse(
|
||||
{"error": "Keine Dienstplan-Events in der PDF gefunden."},
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
events_preview = []
|
||||
for event in dienstplan["events"]:
|
||||
events_preview.append({
|
||||
"date": event["date"].strftime("%d.%m.%Y") if event["date"] else "",
|
||||
"service": event["service"] or "—",
|
||||
"time": f"{event['start_time']}-{event['end_time']}" if event["start_time"] and event["end_time"] else "—",
|
||||
})
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"filename": filename,
|
||||
"metadata": {
|
||||
"name": f"{dienstplan.get('vorname', '')} {dienstplan.get('name', '')}".strip(),
|
||||
"personalnummer": dienstplan.get("personalnummer", "—"),
|
||||
"betriebshof": dienstplan.get("betriebshof", "—"),
|
||||
"count": len(dienstplan["events"]),
|
||||
},
|
||||
"events": events_preview,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/convert")
|
||||
async def convert_pdf(
|
||||
request: Request,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>PDF zu ICS (Web)</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
@@ -13,7 +14,7 @@
|
||||
color: #1f2937;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 520px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 14px 28px;
|
||||
}
|
||||
@@ -24,7 +25,7 @@
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
p {
|
||||
@@ -43,7 +44,6 @@
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.options {
|
||||
margin: 12px 0;
|
||||
@@ -68,8 +68,14 @@
|
||||
color: #fff;
|
||||
margin-top: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button:hover { background: #1d4ed8; }
|
||||
button:active { transform: translateY(1px); }
|
||||
button:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px;
|
||||
@@ -83,38 +89,282 @@
|
||||
font-size: 0.9rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.hidden { display: none; }
|
||||
.preview-header {
|
||||
margin: 0 0 12px;
|
||||
padding: 12px;
|
||||
background: #f0f9ff;
|
||||
border-radius: 10px;
|
||||
border-left: 3px solid #2563eb;
|
||||
}
|
||||
.preview-meta {
|
||||
font-size: 0.9rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.preview-meta-item {
|
||||
color: #4b5563;
|
||||
}
|
||||
.preview-meta-label {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.preview-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.preview-table thead {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.preview-table th, .preview-table td {
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.preview-table th {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
.preview-table tbody tr:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.actions button {
|
||||
margin-top: 0;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="card">
|
||||
<h1>PDF zu ICS Konverter</h1>
|
||||
<p>PDF hochladen, konvertieren und die ICS-Datei direkt herunterladen.</p>
|
||||
<p>PDF hochladen, Vorschau prüfen und herunterladen.</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<div id="error" class="error hidden"></div>
|
||||
|
||||
<form action="/convert" method="post" enctype="multipart/form-data">
|
||||
<!-- UPLOAD-FORMULAR -->
|
||||
<div id="step-init">
|
||||
<label for="file">Dienstplan-PDF</label>
|
||||
<input id="file" name="file" type="file" accept="application/pdf,.pdf" required />
|
||||
<input id="file" type="file" accept="application/pdf,.pdf" />
|
||||
|
||||
<div class="options">
|
||||
<label class="option">
|
||||
<input type="checkbox" name="exclude_rest" value="true" />
|
||||
<input id="exclude_rest" type="checkbox" />
|
||||
Ruhetage ausschließen
|
||||
</label>
|
||||
<label class="option">
|
||||
<input type="checkbox" name="exclude_vacation" value="true" />
|
||||
<input id="exclude_vacation" type="checkbox" />
|
||||
Urlaub ausschließen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit">ICS erstellen</button>
|
||||
</form>
|
||||
<button type="button" onclick="uploadAndPreview()">
|
||||
Vorschau laden
|
||||
</button>
|
||||
|
||||
<div class="hint">Hinweis: Die Datei wird nur temporär für die Konvertierung verarbeitet.</div>
|
||||
<div class="hint">Hinweis: Die Datei wird nur temporär verarbeitet.</div>
|
||||
</div>
|
||||
|
||||
<!-- PREVIEW -->
|
||||
<div id="step2" class="hidden">
|
||||
<div class="preview-header">
|
||||
<div style="font-weight: 600; font-size: 0.95rem;" id="preview-name"></div>
|
||||
<div class="preview-meta">
|
||||
<div class="preview-meta-item">
|
||||
<div class="preview-meta-label">Personalnummer</div>
|
||||
<div id="preview-personalnummer">—</div>
|
||||
</div>
|
||||
<div class="preview-meta-item">
|
||||
<div class="preview-meta-label">Betriebshof</div>
|
||||
<div id="preview-betriebshof">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 8px; color: #4b5563;">
|
||||
<strong id="preview-count"></strong> Ereignisse gefunden
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="preview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Dienstart</th>
|
||||
<th>Zeit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="preview-events"></tbody>
|
||||
</table>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-secondary" onclick="resetForm()">Erneut hochladen</button>
|
||||
<button type="button" onclick="downloadICS()">ICS herunterladen</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let currentFile = null;
|
||||
let currentFilename = null;
|
||||
|
||||
async function uploadAndPreview() {
|
||||
const fileInput = document.getElementById("file");
|
||||
currentFile = fileInput.files[0];
|
||||
currentFilename = currentFile?.name;
|
||||
|
||||
if (!currentFile) {
|
||||
showError("Bitte eine PDF-Datei auswählen.");
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = event.target;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Lädt Vorschau...';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", currentFile);
|
||||
|
||||
const response = await fetch("/preview", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
showError(data.error || "Fehler beim Hochladen");
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Vorschau laden";
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
showError(data.error || "Fehler beim Vorschau-Laden");
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Vorschau laden";
|
||||
return;
|
||||
}
|
||||
|
||||
showPreview(data);
|
||||
hide("step-init");
|
||||
show("step2");
|
||||
} catch (error) {
|
||||
showError(`Fehler: ${error.message}`);
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Vorschau laden";
|
||||
}
|
||||
}
|
||||
|
||||
function showPreview(data) {
|
||||
const { metadata, events } = data;
|
||||
|
||||
document.getElementById("preview-name").textContent = metadata.name;
|
||||
document.getElementById("preview-personalnummer").textContent = metadata.personalnummer;
|
||||
document.getElementById("preview-betriebshof").textContent = metadata.betriebshof;
|
||||
document.getElementById("preview-count").textContent = metadata.count;
|
||||
|
||||
const tbody = document.getElementById("preview-events");
|
||||
tbody.innerHTML = "";
|
||||
events.forEach((event) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${event.date}</td><td>${event.service}</td><td>${event.time}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadICS() {
|
||||
const btn = event.target;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Download...';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", currentFile);
|
||||
formData.append("exclude_rest", document.getElementById("exclude_rest").checked);
|
||||
formData.append("exclude_vacation", document.getElementById("exclude_vacation").checked);
|
||||
|
||||
const response = await fetch("/convert", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
showError("Fehler beim Download");
|
||||
btn.disabled = false;
|
||||
btn.textContent = "ICS herunterladen";
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = currentFilename.replace(/\.pdf$/i, ".ics");
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
showError(`Fehler: ${error.message}`);
|
||||
btn.disabled = false;
|
||||
btn.textContent = "ICS herunterladen";
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
document.getElementById("file").value = "";
|
||||
document.getElementById("exclude_rest").checked = false;
|
||||
document.getElementById("exclude_vacation").checked = false;
|
||||
currentFile = null;
|
||||
currentFilename = null;
|
||||
|
||||
hide("step2");
|
||||
show("step-init");
|
||||
hide("error");
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const el = document.getElementById("error");
|
||||
el.textContent = message;
|
||||
show("error");
|
||||
}
|
||||
|
||||
function show(id) {
|
||||
document.getElementById(id).classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hide(id) {
|
||||
document.getElementById(id).classList.add("hidden");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user