feat: add import dashboard UI and functionality for CSV and Excel uploads

This commit is contained in:
2025-11-10 19:06:27 +01:00
parent 3051f91ab0
commit 51c0fcec95
4 changed files with 270 additions and 77 deletions

View File

@@ -3,6 +3,9 @@ from __future__ import annotations
from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from dependencies import get_import_ingestion_service, require_roles
from models import User
@@ -16,10 +19,30 @@ from schemas.imports import (
from services.importers import ImportIngestionService, UnsupportedImportFormat
router = APIRouter(prefix="/imports", tags=["Imports"])
templates = Jinja2Templates(directory="templates")
MANAGE_ROLES = ("project_manager", "admin")
@router.get(
"/ui",
response_class=HTMLResponse,
include_in_schema=False,
name="imports.ui",
)
def import_dashboard(
request: Request,
_: User = Depends(require_roles(*MANAGE_ROLES)),
) -> HTMLResponse:
return templates.TemplateResponse(
request,
"imports/ui.html",
{
"title": "Imports",
},
)
async def _read_upload_file(upload: UploadFile) -> BytesIO:
content = await upload.read()
if not content:

View File

@@ -1,10 +1,19 @@
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");
const moduleEl = document.querySelector("[data-import-module]");
if (!moduleEl) return;
const dropzone = moduleEl.querySelector("[data-import-dropzone]");
const input = dropzone?.querySelector("input[type='file']");
const uploadButton = moduleEl.querySelector("[data-import-upload-trigger]");
const resetButton = moduleEl.querySelector("[data-import-reset]");
const feedbackEl = moduleEl.querySelector("#import-upload-feedback");
const previewBody = moduleEl.querySelector("[data-import-preview-body]");
const previewContainer = moduleEl.querySelector("#import-preview-container");
const actionsEl = moduleEl.querySelector("[data-import-actions]");
const commitButton = moduleEl.querySelector("[data-import-commit]");
const cancelButton = moduleEl.querySelector("[data-import-cancel]");
let stageToken = null;
function showFeedback(message, type = "info") {
if (!feedbackEl) return;
@@ -19,46 +28,40 @@ document.addEventListener("DOMContentLoaded", () => {
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 clearPreview() {
if (previewBody) {
previewBody.innerHTML = "";
}
previewContainer?.classList.add("hidden");
actionsEl?.classList.add("hidden");
commitButton?.setAttribute("disabled", "disabled");
stageToken = null;
}
function enableUpload() {
if (uploadButton) {
uploadButton.disabled = false;
}
if (resetButton) {
resetButton.hidden = false;
}
uploadButton?.removeAttribute("disabled");
resetButton?.classList.remove("hidden");
}
function disableUpload() {
if (uploadButton) {
uploadButton.disabled = true;
}
if (resetButton) {
resetButton.hidden = true;
}
uploadButton?.setAttribute("disabled", "disabled");
uploadButton?.classList.remove("loading");
resetButton?.classList.add("hidden");
}
zone.addEventListener("dragover", (event) => {
dropzone?.addEventListener("dragover", (event) => {
event.preventDefault();
zone.classList.add("dragover");
dropzone.classList.add("dragover");
});
zone.addEventListener("dragleave", () => {
zone.classList.remove("dragover");
dropzone?.addEventListener("dragleave", () => {
dropzone.classList.remove("dragover");
});
zone.addEventListener("drop", (event) => {
dropzone?.addEventListener("drop", (event) => {
event.preventDefault();
zone.classList.remove("dragover");
if (!event.dataTransfer?.files?.length) {
dropzone.classList.remove("dragover");
if (!event.dataTransfer?.files?.length || !input) {
return;
}
input.files = event.dataTransfer.files;
@@ -66,7 +69,7 @@ document.addEventListener("DOMContentLoaded", () => {
hideFeedback();
});
input.addEventListener("change", () => {
input?.addEventListener("change", () => {
if (input.files?.length) {
enableUpload();
hideFeedback();
@@ -76,13 +79,16 @@ document.addEventListener("DOMContentLoaded", () => {
});
resetButton?.addEventListener("click", () => {
if (input) {
input.value = "";
}
disableUpload();
hideFeedback();
clearPreview();
});
uploadButton?.addEventListener("click", () => {
if (!input.files?.length) {
async function uploadAndPreview() {
if (!input?.files?.length) {
showFeedback(
"Please select a CSV or XLSX file before uploading.",
"error"
@@ -90,16 +96,145 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}
const file = input.files[0];
showFeedback("Uploading…", "info");
uploadButton.disabled = true;
uploadButton.classList.add("loading");
uploadButton?.classList.add("loading");
uploadButton?.setAttribute("disabled", "disabled");
// Actual upload logic handled separately (e.g., fetch).
const formData = new FormData();
formData.append("file", file);
let response;
try {
response = await fetch("/imports/projects/preview", {
method: "POST",
body: formData,
});
} catch (error) {
console.error(error);
NotificationCenter?.show({
message: "Upload started…",
level: "info",
timeout: 2000,
message: "Network error during upload.",
level: "error",
});
showFeedback("Network error during upload.", "error");
uploadButton?.classList.remove("loading");
uploadButton?.removeAttribute("disabled");
return;
}
if (!response.ok) {
const detail = await response.json().catch(() => ({}));
const message = detail?.detail || "Upload failed. Please check the file.";
NotificationCenter?.show({ message, level: "error" });
showFeedback(message, "error");
uploadButton?.classList.remove("loading");
uploadButton?.removeAttribute("disabled");
return;
}
const payload = await response.json();
hideFeedback();
renderPreview(payload);
uploadButton?.classList.remove("loading");
uploadButton?.removeAttribute("disabled");
NotificationCenter?.show({
message: `Preview ready: ${payload.summary.accepted} row(s) accepted`,
level: "success",
});
}
function renderPreview(payload) {
const rows = payload.rows || [];
const issues = payload.row_issues || [];
stageToken = payload.stage_token || null;
if (!previewBody) return;
previewBody.innerHTML = "";
const issueMap = new Map();
issues.forEach((issue) => {
issueMap.set(issue.row_number, issue.issues);
});
rows.forEach((row) => {
const tr = document.createElement("tr");
const rowIssues = issueMap.get(row.row_number) || [];
const issuesText = [
...row.issues,
...rowIssues.map((i) => i.message),
].join(", ");
tr.innerHTML = `
<td>${row.row_number}</td>
<td><span class="badge badge--${row.state}">${row.state}</span></td>
<td>${issuesText || "—"}</td>
${Object.values(row.data)
.map((value) => `<td>${value ?? ""}</td>`)
.join("")}
`;
previewBody.appendChild(tr);
});
previewContainer?.classList.remove("hidden");
if (stageToken && payload.summary.accepted > 0) {
actionsEl?.classList.remove("hidden");
commitButton?.removeAttribute("disabled");
} else {
actionsEl?.classList.add("hidden");
commitButton?.setAttribute("disabled", "disabled");
}
}
uploadButton?.addEventListener("click", uploadAndPreview);
commitButton?.addEventListener("click", async () => {
if (!stageToken) return;
commitButton.classList.add("loading");
commitButton.setAttribute("disabled", "disabled");
let response;
try {
response = await fetch("/imports/projects/commit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: stageToken }),
});
} catch (error) {
console.error(error);
NotificationCenter?.show({
message: "Network error during commit.",
level: "error",
});
commitButton.classList.remove("loading");
commitButton.removeAttribute("disabled");
return;
}
if (!response.ok) {
const detail = await response.json().catch(() => ({}));
const message =
detail?.detail || "Commit failed. Please review the import data.";
NotificationCenter?.show({ message, level: "error" });
commitButton.classList.remove("loading");
commitButton.removeAttribute("disabled");
return;
}
const result = await response.json();
NotificationCenter?.show({
message: `Import committed. Created: ${result.summary.created}, Updated: ${result.summary.updated}`,
level: "success",
});
clearPreview();
if (input) {
input.value = "";
}
disableUpload();
});
cancelButton?.addEventListener("click", () => {
clearPreview();
NotificationCenter?.show({ message: "Import canceled.", level: "info" });
});
});

34
templates/imports/ui.html Normal file
View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% from "partials/alerts.html" import toast %}
{% block title %}Imports · CalMiner{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="/static/css/imports.css" />
{% endblock %}
{% block content %}
<section class="page-header">
<div>
<h1>Data Imports</h1>
<p class="text-muted">Upload CSV or Excel files to preview and commit bulk updates.</p>
</div>
</section>
<section class="card" data-import-module>
<header class="card-header">
<h2>Upload Projects or Scenarios</h2>
</header>
<div class="card-body">
{% include "partials/import_upload.html" %}
{% include "partials/import_preview_table.html" %}
<div class="import-actions hidden" data-import-actions>
<button class="btn primary" data-import-commit disabled>Commit Import</button>
<button class="btn" data-import-cancel>Cancel</button>
</div>
</div>
</section>
{{ toast("import-toast", hidden=True) }}
{% endblock %}

View File

@@ -25,7 +25,8 @@
"links": [
{"href": dashboard_href, "label": "Dashboard", "match_prefix": "/"},
{"href": projects_href, "label": "Projects", "match_prefix": "/projects"},
{"href": project_create_href, "label": "New Project", "match_prefix": "/projects/create"}
{"href": project_create_href, "label": "New Project", "match_prefix": "/projects/create"},
{"href": "/imports/ui", "label": "Imports", "match_prefix": "/imports"}
]
},
{