feat: add import dashboard UI and functionality for CSV and Excel uploads
This commit is contained in:
@@ -3,6 +3,9 @@ from __future__ import annotations
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
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 dependencies import get_import_ingestion_service, require_roles
|
||||||
from models import User
|
from models import User
|
||||||
@@ -16,10 +19,30 @@ from schemas.imports import (
|
|||||||
from services.importers import ImportIngestionService, UnsupportedImportFormat
|
from services.importers import ImportIngestionService, UnsupportedImportFormat
|
||||||
|
|
||||||
router = APIRouter(prefix="/imports", tags=["Imports"])
|
router = APIRouter(prefix="/imports", tags=["Imports"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
MANAGE_ROLES = ("project_manager", "admin")
|
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:
|
async def _read_upload_file(upload: UploadFile) -> BytesIO:
|
||||||
content = await upload.read()
|
content = await upload.read()
|
||||||
if not content:
|
if not content:
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const dropzones = document.querySelectorAll("[data-import-dropzone]");
|
const moduleEl = document.querySelector("[data-import-module]");
|
||||||
const uploadButtons = document.querySelectorAll(
|
if (!moduleEl) return;
|
||||||
"[data-import-upload-trigger]"
|
|
||||||
);
|
const dropzone = moduleEl.querySelector("[data-import-dropzone]");
|
||||||
const resetButtons = document.querySelectorAll("[data-import-reset]");
|
const input = dropzone?.querySelector("input[type='file']");
|
||||||
const feedbackEl = document.querySelector("#import-upload-feedback");
|
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") {
|
function showFeedback(message, type = "info") {
|
||||||
if (!feedbackEl) return;
|
if (!feedbackEl) return;
|
||||||
@@ -19,46 +28,40 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
feedbackEl.classList.add("hidden");
|
feedbackEl.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
dropzones.forEach((zone) => {
|
function clearPreview() {
|
||||||
const input = zone.querySelector("input[type='file']");
|
if (previewBody) {
|
||||||
const uploadButton = zone
|
previewBody.innerHTML = "";
|
||||||
.closest("[data-import-upload]")
|
}
|
||||||
.querySelector("[data-import-upload-trigger]");
|
previewContainer?.classList.add("hidden");
|
||||||
const resetButton = zone
|
actionsEl?.classList.add("hidden");
|
||||||
.closest("[data-import-upload]")
|
commitButton?.setAttribute("disabled", "disabled");
|
||||||
.querySelector("[data-import-reset]");
|
stageToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
function enableUpload() {
|
function enableUpload() {
|
||||||
if (uploadButton) {
|
uploadButton?.removeAttribute("disabled");
|
||||||
uploadButton.disabled = false;
|
resetButton?.classList.remove("hidden");
|
||||||
}
|
|
||||||
if (resetButton) {
|
|
||||||
resetButton.hidden = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function disableUpload() {
|
function disableUpload() {
|
||||||
if (uploadButton) {
|
uploadButton?.setAttribute("disabled", "disabled");
|
||||||
uploadButton.disabled = true;
|
uploadButton?.classList.remove("loading");
|
||||||
}
|
resetButton?.classList.add("hidden");
|
||||||
if (resetButton) {
|
|
||||||
resetButton.hidden = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
zone.addEventListener("dragover", (event) => {
|
dropzone?.addEventListener("dragover", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
zone.classList.add("dragover");
|
dropzone.classList.add("dragover");
|
||||||
});
|
});
|
||||||
|
|
||||||
zone.addEventListener("dragleave", () => {
|
dropzone?.addEventListener("dragleave", () => {
|
||||||
zone.classList.remove("dragover");
|
dropzone.classList.remove("dragover");
|
||||||
});
|
});
|
||||||
|
|
||||||
zone.addEventListener("drop", (event) => {
|
dropzone?.addEventListener("drop", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
zone.classList.remove("dragover");
|
dropzone.classList.remove("dragover");
|
||||||
if (!event.dataTransfer?.files?.length) {
|
if (!event.dataTransfer?.files?.length || !input) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
input.files = event.dataTransfer.files;
|
input.files = event.dataTransfer.files;
|
||||||
@@ -66,7 +69,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
hideFeedback();
|
hideFeedback();
|
||||||
});
|
});
|
||||||
|
|
||||||
input.addEventListener("change", () => {
|
input?.addEventListener("change", () => {
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
enableUpload();
|
enableUpload();
|
||||||
hideFeedback();
|
hideFeedback();
|
||||||
@@ -76,13 +79,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
resetButton?.addEventListener("click", () => {
|
resetButton?.addEventListener("click", () => {
|
||||||
|
if (input) {
|
||||||
input.value = "";
|
input.value = "";
|
||||||
|
}
|
||||||
disableUpload();
|
disableUpload();
|
||||||
hideFeedback();
|
hideFeedback();
|
||||||
|
clearPreview();
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadButton?.addEventListener("click", () => {
|
async function uploadAndPreview() {
|
||||||
if (!input.files?.length) {
|
if (!input?.files?.length) {
|
||||||
showFeedback(
|
showFeedback(
|
||||||
"Please select a CSV or XLSX file before uploading.",
|
"Please select a CSV or XLSX file before uploading.",
|
||||||
"error"
|
"error"
|
||||||
@@ -90,16 +96,145 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const file = input.files[0];
|
||||||
showFeedback("Uploading…", "info");
|
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({
|
NotificationCenter?.show({
|
||||||
message: "Upload started…",
|
message: "Network error during upload.",
|
||||||
level: "info",
|
level: "error",
|
||||||
timeout: 2000,
|
});
|
||||||
});
|
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
34
templates/imports/ui.html
Normal 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 %}
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
"links": [
|
"links": [
|
||||||
{"href": dashboard_href, "label": "Dashboard", "match_prefix": "/"},
|
{"href": dashboard_href, "label": "Dashboard", "match_prefix": "/"},
|
||||||
{"href": projects_href, "label": "Projects", "match_prefix": "/projects"},
|
{"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"}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user