feat: implement export functionality for projects and scenarios with CSV and Excel support

This commit is contained in:
2025-11-10 18:32:24 +01:00
parent 4b33a5dba3
commit 43b1e53837
15 changed files with 906 additions and 133 deletions

86
static/css/imports.css Normal file
View File

@@ -0,0 +1,86 @@
.import-upload {
background-color: var(--surface-color);
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.import-upload__header {
margin-bottom: 1rem;
}
.import-upload__dropzone {
border: 2px dashed var(--border-color);
border-radius: var(--radius-sm);
padding: 2rem;
text-align: center;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.import-upload__dropzone.dragover {
border-color: var(--primary-color);
background-color: rgba(0, 123, 255, 0.05);
}
.import-upload__actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.table-cell-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-ghost {
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
color: var(--text-muted);
}
.btn-ghost:hover {
color: var(--primary-color);
}
.toast {
position: fixed;
right: 1rem;
bottom: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-radius: var(--radius-md);
color: #fff;
box-shadow: var(--shadow-lg);
z-index: 1000;
}
.toast.hidden {
display: none;
}
.toast--success {
background-color: #198754;
}
.toast--error {
background-color: #dc3545;
}
.toast--info {
background-color: #0d6efd;
}
.toast__close {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 1.1rem;
}

97
static/js/exports.js Normal file
View File

@@ -0,0 +1,97 @@
document.addEventListener("DOMContentLoaded", () => {
const modalContainer = document.createElement("div");
modalContainer.id = "export-modal-container";
document.body.appendChild(modalContainer);
async function loadModal(dataset) {
const response = await fetch(`/exports/modal/${dataset}`);
if (!response.ok) {
throw new Error(`Failed to load export modal (${response.status})`);
}
const html = await response.text();
modalContainer.innerHTML = html;
const modal = modalContainer.querySelector(".modal");
if (!modal) return;
modal.classList.add("is-active");
const closeButtons = modal.querySelectorAll("[data-dismiss='modal']");
closeButtons.forEach((btn) =>
btn.addEventListener("click", () => closeModal(modal))
);
const form = modal.querySelector("[data-export-form]");
if (form) {
form.addEventListener("submit", handleSubmit);
}
}
function closeModal(modal) {
modal.classList.remove("is-active");
setTimeout(() => {
modalContainer.innerHTML = "";
}, 200);
}
async function handleSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
const submitUrl = form.action;
const formData = new FormData(form);
const format = formData.get("format") || "csv";
const response = await fetch(submitUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
format,
include_metadata: formData.get("include_metadata") === "true",
filters: null,
}),
});
if (!response.ok) {
alert("Export failed. Please try again.");
return;
}
const blob = await response.blob();
const disposition = response.headers.get("Content-Disposition");
let filename = "export";
if (disposition) {
const match = disposition.match(/filename=([^;]+)/i);
if (match) {
filename = match[1].replace(/"/g, "");
}
}
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
const modal = modalContainer.querySelector(".modal");
if (modal) {
closeModal(modal);
}
}
document.querySelectorAll("[data-export-trigger]").forEach((button) => {
button.addEventListener("click", async (event) => {
event.preventDefault();
const dataset = button.getAttribute("data-export-target");
if (!dataset) return;
try {
await loadModal(dataset);
} catch (error) {
console.error(error);
alert("Unable to open export dialog.");
}
});
});
});

100
static/js/imports.js Normal file
View File

@@ -0,0 +1,100 @@
document.addEventListener("DOMContentLoaded", () => {
const dropzones = document.querySelectorAll("[data-import-dropzone]");
const uploadButtons = document.querySelectorAll(
"[data-import-upload-trigger]"
);
const resetButtons = document.querySelectorAll("[data-import-reset]");
const feedbackEl = document.querySelector("#import-upload-feedback");
function showFeedback(message, type = "info") {
if (!feedbackEl) return;
feedbackEl.textContent = message;
feedbackEl.classList.remove("hidden", "success", "error", "info");
feedbackEl.classList.add(type);
}
function hideFeedback() {
if (!feedbackEl) return;
feedbackEl.textContent = "";
feedbackEl.classList.add("hidden");
}
dropzones.forEach((zone) => {
const input = zone.querySelector("input[type='file']");
const uploadButton = zone
.closest("[data-import-upload]")
.querySelector("[data-import-upload-trigger]");
const resetButton = zone
.closest("[data-import-upload]")
.querySelector("[data-import-reset]");
function enableUpload() {
if (uploadButton) {
uploadButton.disabled = false;
}
if (resetButton) {
resetButton.hidden = false;
}
}
function disableUpload() {
if (uploadButton) {
uploadButton.disabled = true;
}
if (resetButton) {
resetButton.hidden = true;
}
}
zone.addEventListener("dragover", (event) => {
event.preventDefault();
zone.classList.add("dragover");
});
zone.addEventListener("dragleave", () => {
zone.classList.remove("dragover");
});
zone.addEventListener("drop", (event) => {
event.preventDefault();
zone.classList.remove("dragover");
if (!event.dataTransfer?.files?.length) {
return;
}
input.files = event.dataTransfer.files;
enableUpload();
hideFeedback();
});
input.addEventListener("change", () => {
if (input.files?.length) {
enableUpload();
hideFeedback();
} else {
disableUpload();
}
});
resetButton?.addEventListener("click", () => {
input.value = "";
disableUpload();
hideFeedback();
});
uploadButton?.addEventListener("click", () => {
if (!input.files?.length) {
showFeedback(
"Please select a CSV or XLSX file before uploading.",
"error"
);
return;
}
showFeedback("Uploading…", "info");
uploadButton.disabled = true;
uploadButton.classList.add("loading");
// Actual upload logic handled separately (e.g., fetch).
});
});
});