diff --git a/routes/imports.py b/routes/imports.py index 40cffac..7dd85c3 100644 --- a/routes/imports.py +++ b/routes/imports.py @@ -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: diff --git a/static/js/imports.js b/static/js/imports.js index c6fff40..7354693 100644 --- a/static/js/imports.js +++ b/static/js/imports.js @@ -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,87 +28,213 @@ 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 enableUpload() { - if (uploadButton) { - uploadButton.disabled = false; - } - if (resetButton) { - resetButton.hidden = false; - } + function clearPreview() { + if (previewBody) { + previewBody.innerHTML = ""; } + previewContainer?.classList.add("hidden"); + actionsEl?.classList.add("hidden"); + commitButton?.setAttribute("disabled", "disabled"); + stageToken = null; + } - function disableUpload() { - if (uploadButton) { - uploadButton.disabled = true; - } - if (resetButton) { - resetButton.hidden = true; - } + function enableUpload() { + uploadButton?.removeAttribute("disabled"); + resetButton?.classList.remove("hidden"); + } + + function disableUpload() { + uploadButton?.setAttribute("disabled", "disabled"); + uploadButton?.classList.remove("loading"); + resetButton?.classList.add("hidden"); + } + + dropzone?.addEventListener("dragover", (event) => { + event.preventDefault(); + dropzone.classList.add("dragover"); + }); + + dropzone?.addEventListener("dragleave", () => { + dropzone.classList.remove("dragover"); + }); + + dropzone?.addEventListener("drop", (event) => { + event.preventDefault(); + dropzone.classList.remove("dragover"); + if (!event.dataTransfer?.files?.length || !input) { + return; } + input.files = event.dataTransfer.files; + enableUpload(); + hideFeedback(); + }); - 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; + input?.addEventListener("change", () => { + if (input.files?.length) { enableUpload(); hideFeedback(); - }); - - input.addEventListener("change", () => { - if (input.files?.length) { - enableUpload(); - hideFeedback(); - } else { - disableUpload(); - } - }); - - resetButton?.addEventListener("click", () => { - input.value = ""; + } else { disableUpload(); - hideFeedback(); - }); + } + }); - uploadButton?.addEventListener("click", () => { - if (!input.files?.length) { - showFeedback( - "Please select a CSV or XLSX file before uploading.", - "error" - ); - return; - } + resetButton?.addEventListener("click", () => { + if (input) { + input.value = ""; + } + disableUpload(); + hideFeedback(); + clearPreview(); + }); - showFeedback("Uploading…", "info"); - uploadButton.disabled = true; - uploadButton.classList.add("loading"); + async function uploadAndPreview() { + if (!input?.files?.length) { + showFeedback( + "Please select a CSV or XLSX file before uploading.", + "error" + ); + return; + } - // Actual upload logic handled separately (e.g., fetch). - NotificationCenter?.show({ - message: "Upload started…", - level: "info", - timeout: 2000, + const file = input.files[0]; + showFeedback("Uploading…", "info"); + uploadButton?.classList.add("loading"); + uploadButton?.setAttribute("disabled", "disabled"); + + 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: "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 = ` + ${row.row_number} + ${row.state} + ${issuesText || "—"} + ${Object.values(row.data) + .map((value) => `${value ?? ""}`) + .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" }); }); }); diff --git a/templates/imports/ui.html b/templates/imports/ui.html new file mode 100644 index 0000000..c583047 --- /dev/null +++ b/templates/imports/ui.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% from "partials/alerts.html" import toast %} + +{% block title %}Imports · CalMiner{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} + + +
+
+

Upload Projects or Scenarios

+
+
+ {% include "partials/import_upload.html" %} + {% include "partials/import_preview_table.html" %} + + +
+
+ + {{ toast("import-toast", hidden=True) }} +{% endblock %} \ No newline at end of file diff --git a/templates/partials/sidebar_nav.html b/templates/partials/sidebar_nav.html index 90e3c1a..64c7115 100644 --- a/templates/partials/sidebar_nav.html +++ b/templates/partials/sidebar_nav.html @@ -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"} ] }, {