feat(msp): Editorial-Design-Redesign von Dropzone + Wizard mit Statistiken

Komplettes Layout-Rework beider Pages in einem konsistenten Editorial-Data-
Look (distinctive Typografie, klare Grids, itsdave-Rot-Akzent).

Dropzone:
- Hero-Header mit Titel, Kontext-Text und Handler-Liste
- 4-Kachel-Statistik-Dashboard (Runs / Dokumente / Fehler / Subscriptions)
- Orb-Dropzone mit dezenter Animation + manuelle Handler-Auswahl
- Liste der letzten Runs mit Status-Badges

Wizard:
- Stepper-Header (01 Analyse / 02 Konfiguration / 03 Ergebnis)
- Schritt 1: Hero-Stats (Zeilen / Kunden / Produkte / Zeitraum) mit
  roter Gutschrift-Kachel + Warnungen
- Schritt 3: Big-Counter-Cards + Top-Kunden-Balkenvisualisierung +
  Dokument-Tabelle mit Doctype-Badges

Backend:
- dashboard_stats(): aggregierte Zahlen + letzte Runs
- customer_context(): pro-Kunde Subscriptions/Coverage/Existenz
- run_summary(): Totals + Top-N-Kunden pro Run

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Malinowski
2026-04-14 22:16:20 +02:00
co-authored by Claude Opus 4.6
parent a2d11476d1
commit 9a973d92ee
3 changed files with 1376 additions and 387 deletions
+171 -2
View File
@@ -1,9 +1,13 @@
"""HTTP-Endpoints für die Import-Dropzone-Page.
"""HTTP-Endpoints für die Import-Dropzone- und Assistent-Pages.
Drei whitelisted Methoden:
Whitelisted Methoden:
- analyze_file(file_url): Detect handler + DB-freie Vorschau
- process_file(file_url, handler_key, config): Führt den Import aus
- list_handlers(): Metadaten aller registrierten Handler (für manuelle Auswahl)
- run_documents(run_name): erzeugte Zieldokumente eines Runs (gruppiert)
- dashboard_stats(): Statistiken + letzte Runs für die Dropzone-Page
- customer_context(customer_refs): pro-Kunde-Kontext (bekannt/neu, Coverage, letzter Run)
- run_summary(run_name): aggregierte Top-Kunden-Summen eines Runs
"""
from __future__ import annotations
@@ -149,6 +153,171 @@ def process_file(file_url: str, handler_key: str,
}
@frappe.whitelist()
def dashboard_stats(months: int = 1, recent_count: int = 6) -> dict:
"""Kennzahlen für die Dropzone-Startseite.
Liefert eine Aktivitäts-Zusammenfassung der letzten ``months`` Monate plus
die letzten ``recent_count`` Import-Runs inklusive Kurzstatistik.
"""
from_date = frappe.utils.add_months(frappe.utils.nowdate(), -int(months))
totals_row = frappe.db.sql(
"""
SELECT
COUNT(*) AS total_runs,
SUM(CASE WHEN status = 'Succeeded' THEN 1 ELSE 0 END) AS succeeded,
SUM(CASE WHEN status = 'Partial' THEN 1 ELSE 0 END) AS partial,
SUM(CASE WHEN status = 'Failed' THEN 1 ELSE 0 END) AS failed,
COALESCE(SUM(document_count), 0) AS total_documents,
COALESCE(SUM(line_count), 0) AS total_lines,
COALESCE(SUM(error_count), 0) AS total_errors
FROM `tabSupplier Import Run`
WHERE creation >= %(from_date)s
""",
{"from_date": from_date},
as_dict=True,
)
totals = totals_row[0] if totals_row else {}
subs_row = frappe.db.sql(
"""SELECT COUNT(*) AS active, COALESCE(SUM(qty_current), 0) AS total_qty
FROM `tabSupply Subscription` WHERE status = 'Active'""",
as_dict=True,
)
subs = subs_row[0] if subs_row else {}
recent = frappe.db.sql(
"""
SELECT name, profile, status, creation, finished_at,
line_count, document_count, error_count
FROM `tabSupplier Import Run`
ORDER BY creation DESC
LIMIT %(limit)s
""",
{"limit": int(recent_count)},
as_dict=True,
)
return {
"window": {"months": int(months), "from_date": str(from_date)},
"totals": {
"runs": int(totals.get("total_runs") or 0),
"succeeded": int(totals.get("succeeded") or 0),
"partial": int(totals.get("partial") or 0),
"failed": int(totals.get("failed") or 0),
"documents": int(totals.get("total_documents") or 0),
"lines": int(totals.get("total_lines") or 0),
"errors": int(totals.get("total_errors") or 0),
},
"subscriptions": {
"active": int(subs.get("active") or 0),
"total_qty": int(subs.get("total_qty") or 0),
},
"recent_runs": recent,
}
@frappe.whitelist()
def customer_context(customer_refs: str | list) -> list[dict]:
"""Kundenkontext für den Wizard Schritt 1.
Für jeden Kunden-Ref: hat er Supply Subscriptions (bekannt?), hat er eine
aktive License Coverage, wie viele Runs waren's bisher?
"""
if isinstance(customer_refs, str):
customer_refs = json.loads(customer_refs or "[]")
refs = [c for c in (customer_refs or []) if c]
if not refs:
return []
subs = frappe.db.sql(
"""
SELECT customer, COUNT(*) AS n_subscriptions,
COALESCE(SUM(qty_current), 0) AS qty_total
FROM `tabSupply Subscription`
WHERE customer IN %(refs)s
GROUP BY customer
""",
{"refs": tuple(refs)},
as_dict=True,
)
subs_map = {r["customer"]: r for r in subs}
coverage = frappe.db.sql(
"""
SELECT c.customer, COUNT(DISTINCT c.name) AS n_coverages,
COALESCE(SUM(ci.qty), 0) AS covered_qty
FROM `tabLicense Coverage` c
LEFT JOIN `tabLicense Coverage Item` ci ON ci.parent = c.name
WHERE c.enabled = 1 AND c.customer IN %(refs)s
GROUP BY c.customer
""",
{"refs": tuple(refs)},
as_dict=True,
)
cov_map = {r["customer"]: r for r in coverage}
customers_exist = set(frappe.db.sql_list(
"SELECT name FROM tabCustomer WHERE name IN %(refs)s",
{"refs": tuple(refs)},
))
rows = []
for ref in refs:
s = subs_map.get(ref, {})
c = cov_map.get(ref, {})
rows.append({
"customer": ref,
"exists": ref in customers_exist,
"subscriptions": int(s.get("n_subscriptions") or 0),
"subscription_qty": int(s.get("qty_total") or 0),
"coverages": int(c.get("n_coverages") or 0),
"covered_qty": int(c.get("covered_qty") or 0),
})
return rows
@frappe.whitelist()
def run_summary(run_name: str, top_n: int = 5) -> dict:
"""Aggregierte Zusammenfassung eines Runs: Totals + Top-N-Kunden."""
if not run_name:
return {}
totals = frappe.db.sql(
"""
SELECT COUNT(DISTINCT customer) AS customers,
COUNT(DISTINCT target_doc_name) AS documents,
COALESCE(SUM(CASE WHEN line_status = 'Created' THEN amount END), 0) AS total_created,
SUM(CASE WHEN line_status = 'Created' THEN 1 ELSE 0 END) AS lines_created,
SUM(CASE WHEN line_status = 'Skipped' THEN 1 ELSE 0 END) AS lines_skipped,
SUM(CASE WHEN line_status = 'Error' THEN 1 ELSE 0 END) AS lines_error
FROM `tabSupplier Import Line`
WHERE parent = %(run)s
""",
{"run": run_name},
as_dict=True,
)
top = frappe.db.sql(
"""
SELECT customer,
COUNT(DISTINCT target_doc_name) AS documents,
COUNT(*) AS line_count,
COALESCE(SUM(amount), 0) AS total_amount
FROM `tabSupplier Import Line`
WHERE parent = %(run)s AND line_status = 'Created'
GROUP BY customer
ORDER BY total_amount DESC
LIMIT %(limit)s
""",
{"run": run_name, "limit": int(top_n)},
as_dict=True,
)
return {
"totals": totals[0] if totals else {},
"top_customers": top,
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
+613 -174
View File
@@ -1,10 +1,9 @@
/**
* Frappe-Page „Lieferanten-Import" — Drag-&-Drop-Dropzone für CSV/ZIP mit
* automatischer Format-Erkennung.
* Frappe-Page „Lieferanten-Import" — Drag-&-Drop-Dropzone mit Dashboard.
*
* Pro Datei: Upload → Analyse (msp.importers.assistant.analyze_file) →
* Karte mit Statistik und „Assistent öffnen"-Button. Unbekannte Formate
* bieten manuelle Handler-Auswahl an.
* Layout-Konzept: Editorial Data. Große Zahlen als visueller Anker,
* klare Typografie-Hierarchie, Grautöne für Struktur, ERPNext-Blau als
* Primär, itsdave-Rot als sparsam eingesetzter Akzent.
*/
frappe.pages["import-dropzone"].on_page_load = function (wrapper) {
@@ -13,131 +12,193 @@ frappe.pages["import-dropzone"].on_page_load = function (wrapper) {
title: __("Lieferanten-Import"),
single_column: true,
});
// Frappe übergibt ``page.main`` als jQuery-Wrapper — wir brauchen das native Element.
const root = page.main[0] || page.main;
root.innerHTML = `
<div class="msp-dropzone-page">
<div class="msp-dropzone" tabindex="0">
<div class="msp-dropzone__icon">⬆</div>
<div class="msp-dropzone__label">${__("Dateien per Drag & Drop ablegen oder")}
<button class="btn btn-primary btn-sm msp-dropzone__browse">
<div class="msp-importer">
<header class="msp-hero">
<div class="msp-hero__lede">
<p class="msp-eyebrow">${__("Lieferanten-Import")}</p>
<h1 class="msp-title">${__("Dateien analysieren & Belege erzeugen")}</h1>
<p class="msp-sub">${__("CSV oder ZIP hier einwerfen — das System erkennt das Format, zeigt eine Vorschau und führt dich durch den Import.")}</p>
</div>
<div class="msp-stats" data-role="stats">
<div class="msp-stat msp-stat--skeleton"></div>
<div class="msp-stat msp-stat--skeleton"></div>
<div class="msp-stat msp-stat--skeleton"></div>
<div class="msp-stat msp-stat--skeleton"></div>
</div>
</header>
<section class="msp-dropzone" tabindex="0">
<div class="msp-dropzone__inner">
<div class="msp-dropzone__orb" aria-hidden="true"></div>
<div class="msp-dropzone__copy">
<div class="msp-dropzone__label">
${__("Dateien ablegen")}
</div>
<div class="msp-dropzone__hint">
${__("CSV · ZIP (mit einer CSV) — mehrere Dateien gleichzeitig möglich")}
</div>
</div>
<button class="btn btn-primary btn-sm msp-dropzone__browse" type="button">
${__("Datei auswählen")}
</button>
<input type="file" class="msp-dropzone__input" multiple hidden
accept=".csv,.zip,application/zip,application/x-zip-compressed,text/csv" />
</div>
<div class="msp-dropzone__hint">
${__("Unterstützt: CSV · ZIP (mit einer CSV)")}
</section>
<div class="msp-cards" data-role="cards"></div>
<section class="msp-recent" data-role="recent">
<div class="msp-section-head">
<h2 class="msp-section-title">${__("Letzte Läufe")}</h2>
<a class="msp-section-link" href="/app/supplier-import-run">
${__("Alle anzeigen")} →
</a>
</div>
<input type="file" class="msp-dropzone__input" multiple hidden
accept=".csv,.zip,application/zip,application/x-zip-compressed,text/csv" />
</div>
<div class="msp-dropzone-cards"></div>
<div class="msp-recent__list">
<div class="msp-recent__empty">${__("Noch keine Läufe vorhanden.")}</div>
</div>
</section>
</div>
`;
// Styles inline — spart einen zusätzlichen Bundle-Step.
const style = document.createElement("style");
style.textContent = `
.msp-dropzone-page { padding: 10px; }
.msp-dropzone {
border: 2px dashed var(--border-color);
border-radius: 8px;
padding: 40px 24px;
text-align: center;
transition: background-color .15s, border-color .15s;
cursor: pointer;
background: var(--bg-color);
}
.msp-dropzone.is-dragover {
border-color: var(--primary);
background: var(--bg-light-gray);
}
.msp-dropzone__icon { font-size: 42px; opacity: .6; margin-bottom: 8px; }
.msp-dropzone__label { font-size: 15px; margin-bottom: 6px; }
.msp-dropzone__hint { color: var(--text-muted); font-size: 12px; }
.msp-dropzone__browse { margin-left: 6px; }
.msp-dropzone-cards {
display: flex; flex-direction: column; gap: 12px;
margin-top: 20px;
}
.msp-card {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 14px 16px;
background: var(--card-bg, var(--fg-color));
display: flex; gap: 16px; align-items: flex-start;
}
.msp-card__icon {
width: 40px; height: 40px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 18px; flex: 0 0 40px;
background: var(--bg-light-gray);
}
.msp-card__body { flex: 1; min-width: 0; }
.msp-card__title { font-weight: 600; margin-bottom: 2px; display: flex; gap: 8px; align-items: baseline; }
.msp-card__subtitle { color: var(--text-muted); font-size: 12px; }
.msp-card__stats { margin-top: 8px; font-size: 13px; color: var(--text-color); }
.msp-card__stats .stat { display: inline-block; margin-right: 14px; }
.msp-card__stats .stat strong { color: var(--heading-color); }
.msp-card__warn { color: var(--orange-500); font-size: 12px; margin-top: 6px; }
.msp-card__error { color: var(--red-600); font-size: 12px; margin-top: 6px; white-space: pre-wrap; }
.msp-card__actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.msp-card--uploading, .msp-card--analyzing { opacity: .85; }
.msp-card--matched { border-left: 3px solid var(--green-500); }
.msp-card--unmatched { border-left: 3px solid var(--orange-500); }
.msp-card--error { border-left: 3px solid var(--red-500); }
`;
root.appendChild(style);
inject_style(root);
const dz = root.querySelector(".msp-dropzone");
const cards = root.querySelector(".msp-dropzone-cards");
const fileInput = root.querySelector(".msp-dropzone__input");
const els = {
dz: root.querySelector(".msp-dropzone"),
cards: root.querySelector('[data-role="cards"]'),
stats: root.querySelector('[data-role="stats"]'),
recent: root.querySelector('[data-role="recent"] .msp-recent__list'),
fileInput: root.querySelector(".msp-dropzone__input"),
};
// Drag & Drop
["dragenter", "dragover"].forEach((ev) =>
dz.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
dz.classList.add("is-dragover");
})
);
["dragleave", "drop"].forEach((ev) =>
dz.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
dz.classList.remove("is-dragover");
})
);
dz.addEventListener("drop", (e) => handle_files(e.dataTransfer.files));
dz.addEventListener("click", (e) => {
if (e.target.classList.contains("msp-dropzone__browse") || e.target === dz) {
fileInput.click();
}
});
fileInput.addEventListener("change", (e) => handle_files(e.target.files));
function handle_files(file_list) {
if (!file_list || !file_list.length) return;
for (const file of file_list) {
process_one_file(file);
}
}
// Für manuelle Zuordnung: Liste aller Handler beim Seitenaufruf laden
// --- Dashboard-Daten nachladen ------------------------------------
let HANDLERS = [];
frappe.call({
method: "msp.importers.assistant.list_handlers",
callback: (r) => (HANDLERS = r.message || []),
});
frappe.call({
method: "msp.importers.assistant.dashboard_stats",
args: { months: 1, recent_count: 6 },
callback: (r) => render_dashboard(r.message || {}),
});
// --- Drag & Drop --------------------------------------------------
["dragenter", "dragover"].forEach((ev) =>
els.dz.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
els.dz.classList.add("is-dragover");
})
);
["dragleave", "drop"].forEach((ev) =>
els.dz.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
els.dz.classList.remove("is-dragover");
})
);
els.dz.addEventListener("drop", (e) => handle_files(e.dataTransfer.files));
els.dz.addEventListener("click", (e) => {
if (e.target.classList.contains("msp-dropzone__browse") ||
e.target.closest(".msp-dropzone__inner") === e.currentTarget.querySelector(".msp-dropzone__inner")) {
if (e.target.tagName !== "BUTTON" && !e.target.classList.contains("msp-dropzone__browse")) {
// Klick auf die Zone selbst → File-Dialog
els.fileInput.click();
}
}
});
root.querySelector(".msp-dropzone__browse").addEventListener("click", (e) => {
e.stopPropagation();
els.fileInput.click();
});
els.fileInput.addEventListener("change", (e) => handle_files(e.target.files));
function handle_files(file_list) {
if (!file_list || !file_list.length) return;
for (const file of file_list) process_one_file(file);
}
// --- Dashboard --------------------------------------------------------
function render_dashboard(data) {
const t = data.totals || {};
const s = data.subscriptions || {};
const stats = [
{ label: __("Läufe (30 Tage)"), value: t.runs || 0,
sub: build_status_strip(t), accent: "primary" },
{ label: __("Erzeugte Belege"), value: t.documents || 0,
sub: fmt_line(t.lines, __("Zeilen verarbeitet")), accent: "neutral" },
{ label: __("Fehler"), value: t.errors || 0,
sub: t.errors ? __("bitte prüfen") : __("keine auffälligen"),
accent: t.errors ? "danger" : "neutral" },
{ label: __("Aktive Subscriptions"), value: s.active || 0,
sub: `${s.total_qty || 0} ${__("Slots insgesamt")}`,
accent: "accent" },
];
els.stats.innerHTML = stats.map(render_stat).join("");
const recent = data.recent_runs || [];
els.recent.innerHTML = recent.length
? recent.map(render_recent_row).join("")
: `<div class="msp-recent__empty">${__("Noch keine Läufe vorhanden.")}</div>`;
}
function render_stat(s) {
return `
<div class="msp-stat msp-stat--${s.accent}">
<div class="msp-stat__value">${format_int(s.value)}</div>
<div class="msp-stat__label">${s.label}</div>
<div class="msp-stat__sub">${s.sub || ""}</div>
</div>
`;
}
function build_status_strip(t) {
const parts = [];
if (t.succeeded) parts.push(`<span class="pill pill--ok">${t.succeeded} ${__("erfolgreich")}</span>`);
if (t.partial) parts.push(`<span class="pill pill--warn">${t.partial} ${__("partiell")}</span>`);
if (t.failed) parts.push(`<span class="pill pill--danger">${t.failed} ${__("fehlgeschlagen")}</span>`);
return parts.join(" ") || `<span class="pill pill--muted">${__("—")}</span>`;
}
function fmt_line(n, suffix) {
if (!n) return `<span class="muted">${__("—")}</span>`;
return `${format_int(n)} ${suffix}`;
}
function render_recent_row(r) {
const status_cls = {
"Succeeded": "ok", "Partial": "warn", "Failed": "danger",
"Running": "accent", "Pending": "muted",
}[r.status] || "muted";
const dt = r.creation ? format_relative(r.creation) : "";
return `
<a class="msp-recent__row" href="/app/supplier-import-run/${encodeURIComponent(r.name)}">
<span class="msp-recent__status"><span class="dot dot--${status_cls}"></span>${frappe.utils.escape_html(r.status || "")}</span>
<span class="msp-recent__name">${frappe.utils.escape_html(r.name)}</span>
<span class="msp-recent__profile">${frappe.utils.escape_html(r.profile || "")}</span>
<span class="msp-recent__nums">
<strong>${format_int(r.document_count || 0)}</strong> ${__("Docs")} ·
<strong>${format_int(r.line_count || 0)}</strong> ${__("Zeilen")}${r.error_count ? ` · <span class="danger">${r.error_count} ${__("Fehler")}</span>` : ""}
</span>
<span class="msp-recent__time">${dt}</span>
</a>
`;
}
// --- Upload-Handling ----------------------------------------------
async function process_one_file(file) {
const card = make_card(file);
cards.prepend(card.el);
els.cards.prepend(card.el);
try {
card.set_state("uploading");
const file_url = await upload_file(file, (pct) => card.set_progress(pct));
card.set_state("analyzing");
const analysis = await frappe.call({
method: "msp.importers.assistant.analyze_file",
@@ -151,24 +212,24 @@ frappe.pages["import-dropzone"].on_page_load = function (wrapper) {
}
function make_card(file) {
const el = document.createElement("div");
const el = document.createElement("article");
el.className = "msp-card msp-card--uploading";
el.innerHTML = `
<div class="msp-card__icon">${icon_for(file.name)}</div>
<div class="msp-card__badge">${icon_for(file.name)}</div>
<div class="msp-card__body">
<div class="msp-card__title">
<span class="name"></span>
<span class="msp-card__subtitle"></span>
</div>
<div class="msp-card__state"><span class="spinner">⏳</span> ${__("Warte …")}</div>
<header class="msp-card__head">
<h3 class="msp-card__name"></h3>
<span class="msp-card__size"></span>
</header>
<div class="msp-card__state"><span class="spinner"></span> <em>${__("Warte …")}</em></div>
<div class="msp-card__stats" hidden></div>
<div class="msp-card__warn" hidden></div>
<div class="msp-card__error" hidden></div>
<div class="msp-card__actions" hidden></div>
</div>
`;
el.querySelector(".name").textContent = file.name;
el.querySelector(".msp-card__subtitle").textContent = format_size(file.size);
el.querySelector(".msp-card__name").textContent = file.name;
el.querySelector(".msp-card__size").textContent = format_size(file.size);
return {
el,
@@ -183,16 +244,16 @@ frappe.pages["import-dropzone"].on_page_load = function (wrapper) {
uploading: __("Lade hoch …"),
analyzing: __("Analysiere …"),
}[state] || text || "";
if (label) state_el.innerHTML = `<span class="spinner">⏳</span> ${label}`;
if (label) state_el.innerHTML = `<span class="spinner"></span> <em>${label}</em>`;
else state_el.innerHTML = "";
},
set_progress(pct) {
el.querySelector(".msp-card__state").innerHTML =
`<span class="spinner">⏳</span> ${__("Lade hoch …")} ${Math.round(pct)}%`;
`<span class="spinner"></span> <em>${__("Lade hoch …")} ${Math.round(pct)}%</em>`;
},
set_error(msg) {
el.classList.add("msp-card--error");
el.querySelector(".msp-card__state").innerHTML = `❌ ${__("Fehler")}`;
el.querySelector(".msp-card__state").innerHTML = `<span class="cross">✕</span> ${__("Fehler")}`;
const err_el = el.querySelector(".msp-card__error");
err_el.hidden = false;
err_el.textContent = msg;
@@ -205,7 +266,6 @@ frappe.pages["import-dropzone"].on_page_load = function (wrapper) {
const state_el = el.querySelector(".msp-card__state");
const stats_el = el.querySelector(".msp-card__stats");
const warn_el = el.querySelector(".msp-card__warn");
const err_el = el.querySelector(".msp-card__error");
const actions_el = el.querySelector(".msp-card__actions");
if (result.error) {
@@ -214,17 +274,14 @@ frappe.pages["import-dropzone"].on_page_load = function (wrapper) {
}
if (!result.handler_key) {
// Format nicht erkannt — manuelle Zuordnung ermöglichen
el.classList.remove("msp-card--analyzing");
el.classList.add("msp-card--unmatched");
state_el.innerHTML = `⚠ ${__("Format nicht erkannt")}`;
state_el.innerHTML = `<span class="cross cross--warn">!</span> <strong>${__("Format nicht erkannt")}</strong>`;
warn_el.hidden = false;
warn_el.textContent = __(
"Keiner der verfügbaren Handler konnte die Datei zuverlässig erkennen. Bitte manuell zuordnen:"
);
warn_el.textContent = __("Keiner der verfügbaren Handler hat die Datei zuverlässig erkannt. Bitte manuell zuordnen:");
actions_el.hidden = false;
actions_el.innerHTML = `
<select class="form-control form-control-sm" style="max-width: 280px;">
<select class="form-control form-control-sm msp-input">
<option value="">${__("Handler wählen …")}</option>
${HANDLERS.map((h) =>
`<option value="${h.handler_key}">${frappe.utils.escape_html(h.display_name)}</option>`
@@ -236,63 +293,61 @@ frappe.pages["import-dropzone"].on_page_load = function (wrapper) {
`;
const select = actions_el.querySelector("select");
const btn = actions_el.querySelector(".btn-open-assistant");
select.addEventListener("change", () => {
btn.disabled = !select.value;
});
btn.addEventListener("click", () => open_assistant(file_url, select.value));
select.addEventListener("change", () => (btn.disabled = !select.value));
btn.addEventListener("click", () =>
open_assistant(file_url, select.value, {}, { display_name: null, preview: null })
);
return;
}
// Erkannt
el.classList.remove("msp-card--analyzing");
el.classList.add("msp-card--matched");
state_el.innerHTML = `✓ ${__("Erkannt")}: <strong>${frappe.utils.escape_html(result.display_name)}</strong>`
+ ` <span class="msp-card__subtitle">(${Math.round(result.confidence * 100)}%)</span>`;
state_el.innerHTML = `
<span class="check">✓</span>
<strong>${frappe.utils.escape_html(result.display_name)}</strong>
<span class="muted">· ${Math.round(result.confidence * 100)}% ${__("Confidence")}</span>
`;
const preview = result.preview || {};
const stat_parts = [];
if (preview.line_count != null) stat_parts.push(`<span class="stat"><strong>${preview.line_count}</strong> ${__("Zeilen")}</span>`);
if (preview.customer_count != null) stat_parts.push(`<span class="stat"><strong>${preview.customer_count}</strong> ${__("Kunden")}</span>`);
if (preview.product_count != null) stat_parts.push(`<span class="stat"><strong>${preview.product_count}</strong> ${__("Produkte")}</span>`);
if (preview.period_label) stat_parts.push(`<span class="stat">${frappe.utils.escape_html(preview.period_label)}</span>`);
if (preview.credit_note_lines) stat_parts.push(`<span class="stat">${preview.credit_note_lines} ${__("Gutschriftzeilen")}</span>`);
stats_el.hidden = false;
stats_el.innerHTML = stat_parts.join(" ");
const stats = [];
if (preview.line_count != null) stats.push(["Zeilen", preview.line_count]);
if (preview.customer_count != null) stats.push(["Kunden", preview.customer_count]);
if (preview.product_count != null) stats.push(["Produkte", preview.product_count]);
if (preview.period_label) stats.push(["Zeitraum", preview.period_label, true]);
if (preview.credit_note_lines) stats.push(["Gutschriftzeilen", preview.credit_note_lines, false, "warn"]);
const warnings = preview.warnings || [];
stats_el.hidden = false;
stats_el.innerHTML = stats.map(([label, val, isText, pill]) => `
<div class="msp-chip ${pill ? `msp-chip--${pill}` : ""}">
<span class="msp-chip__label">${__(label)}</span>
<span class="msp-chip__value">${isText ? frappe.utils.escape_html(String(val)) : format_int(val)}</span>
</div>
`).join("");
const warnings = [...(preview.warnings || [])];
if (preview.format_drift) warnings.unshift(__("Format weicht vom erwarteten Layout ab"));
if (warnings.length) {
warn_el.hidden = false;
warn_el.innerHTML = warnings.map((w) => `⚠ ${frappe.utils.escape_html(w)}`).join("<br>");
warn_el.innerHTML = warnings.map((w) => `<div>⚠ ${frappe.utils.escape_html(w)}</div>`).join("");
}
actions_el.hidden = false;
actions_el.innerHTML = `
<button class="btn btn-primary btn-sm btn-open-assistant">
${__("Assistent öffnen")} →
${__("Assistent öffnen")} <span class="arr">→</span>
</button>
`;
actions_el
.querySelector(".btn-open-assistant")
.addEventListener("click", () =>
open_assistant(
file_url,
result.handler_key,
{
wizard_route: result.wizard_route,
format_drift: preview.format_drift,
suggested_profile: preview.suggested_profile,
display_name: result.display_name,
},
result
)
);
actions_el.querySelector(".btn-open-assistant").addEventListener("click", () =>
open_assistant(file_url, result.handler_key, {
wizard_route: result.wizard_route,
format_drift: preview.format_drift,
suggested_profile: preview.suggested_profile,
display_name: result.display_name,
}, result)
);
}
function open_assistant(file_url, handler_key, opts = {}, analysis = {}) {
// Kontext für den Wizard in route_options ablegen — Frappe überträgt das
// beim set_route in die Ziel-Page, dort wird es ausgelesen und sofort
// gelöscht.
frappe.route_options = {
file_url,
handler_key,
@@ -301,21 +356,20 @@ frappe.pages["import-dropzone"].on_page_load = function (wrapper) {
format_drift: !!opts.format_drift,
preview: analysis.preview || null,
};
const route = opts.wizard_route || "import-assistant";
frappe.set_route(route);
frappe.set_route(opts.wizard_route || "import-assistant");
}
// --- HTTP / Utils ---------------------------------------------------
async function upload_file(file, on_progress) {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append("file", file, file.name);
form.append("is_private", "1");
form.append("folder", "Home");
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/method/upload_file");
xhr.setRequestHeader("X-Frappe-CSRF-Token", frappe.csrf_token);
xhr.upload.onprogress = (e) => {
if (on_progress && e.lengthComputable) on_progress((e.loaded / e.total) * 100);
};
@@ -326,9 +380,7 @@ frappe.pages["import-dropzone"].on_page_load = function (wrapper) {
const file_url = (json.message || {}).file_url;
if (!file_url) return reject(new Error("Upload: keine file_url in Antwort"));
resolve(file_url);
} catch (e) {
reject(e);
}
} catch (e) { reject(e); }
} else {
reject(new Error(`Upload fehlgeschlagen (HTTP ${xhr.status})`));
}
@@ -344,11 +396,398 @@ frappe.pages["import-dropzone"].on_page_load = function (wrapper) {
return (bytes / 1024 / 1024).toFixed(1) + " MB";
}
function format_int(n) {
return Number(n || 0).toLocaleString("de-DE");
}
function format_relative(datetime_str) {
const then = new Date(datetime_str.replace(" ", "T"));
const diff = (Date.now() - then.getTime()) / 1000;
if (diff < 60) return __("gerade eben");
if (diff < 3600) return `${Math.round(diff / 60)} ${__("Min.")}`;
if (diff < 86400) return `${Math.round(diff / 3600)} ${__("h")}`;
if (diff < 86400 * 7) return `${Math.round(diff / 86400)} ${__("Tage")}`;
return then.toLocaleDateString("de-DE");
}
function icon_for(filename) {
const name = (filename || "").toLowerCase();
if (name.endsWith(".zip")) return "🗜️";
if (name.endsWith(".csv")) return "📄";
if (name.endsWith(".xlsx") || name.endsWith(".xls")) return "📊";
return "📎";
if (name.endsWith(".zip")) return "<svg viewBox='0 0 24 24' width='20' height='20' fill='none' stroke='currentColor' stroke-width='1.6'><rect x='4' y='3' width='16' height='18' rx='2'/><path d='M10 3v4h4V3'/><path d='M10 9v2h4v2h-4v2h4v2h-4'/></svg>";
if (name.endsWith(".csv")) return "<svg viewBox='0 0 24 24' width='20' height='20' fill='none' stroke='currentColor' stroke-width='1.6'><path d='M6 3h9l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z'/><path d='M15 3v4h4'/><path d='M8 14h2M12 14h2M16 14h1'/><path d='M8 18h2M12 18h2M16 18h1'/></svg>";
return "<svg viewBox='0 0 24 24' width='20' height='20' fill='none' stroke='currentColor' stroke-width='1.6'><path d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/><path d='M14 2v6h6'/></svg>";
}
};
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
function inject_style(root) {
const style = document.createElement("style");
style.textContent = `
.msp-importer {
--ink: #0f172a;
--ink-soft: #475569;
--mute: #94a3b8;
--line: #e2e8f0;
--surface: #ffffff;
--surface-alt: #f8fafc;
--primary: var(--primary, #2490ef);
--primary-ink: #0b4a8a;
--accent: #e73249; /* itsdave rot */
--ok: #047857;
--warn: #b45309;
--danger: #b91c1c;
--muted-bg: #f1f5f9;
max-width: 1080px;
margin: 0 auto;
padding: 4px 4px 40px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
color: var(--ink);
}
.msp-hero {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.4fr);
gap: 40px;
align-items: end;
padding: 36px 0 28px;
border-bottom: 1px solid var(--line);
margin-bottom: 28px;
}
@media (max-width: 880px) { .msp-hero { grid-template-columns: 1fr; gap: 24px; } }
.msp-eyebrow {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .14em;
color: var(--mute);
margin: 0 0 10px 0;
font-weight: 600;
}
.msp-title {
font-size: 34px;
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.015em;
margin: 0 0 12px 0;
color: var(--ink);
}
.msp-sub {
font-size: 14.5px;
line-height: 1.55;
color: var(--ink-soft);
max-width: 46ch;
margin: 0;
}
.msp-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
@media (max-width: 640px) { .msp-stats { grid-template-columns: repeat(2, 1fr); } }
.msp-stat {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 10px;
padding: 14px 14px 12px;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
overflow: hidden;
animation: msp-fade-in .4s ease both;
}
.msp-stat::before {
content: "";
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
}
.msp-stat--primary::before { background: var(--primary); }
.msp-stat--accent::before { background: var(--accent); }
.msp-stat--neutral::before { background: var(--line); }
.msp-stat--danger::before { background: var(--danger); }
.msp-stat--skeleton {
background: linear-gradient(90deg, var(--muted-bg) 0%, #e8eef5 50%, var(--muted-bg) 100%);
background-size: 200% 100%;
animation: msp-shimmer 1.2s linear infinite;
min-height: 92px;
}
.msp-stat__value {
font-size: 30px;
font-weight: 600;
line-height: 1;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
color: var(--ink);
}
.msp-stat__label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .1em;
color: var(--mute);
font-weight: 600;
}
.msp-stat__sub { font-size: 12px; color: var(--ink-soft); margin-top: 2px; }
.pill {
display: inline-block;
padding: 2px 7px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
margin-right: 4px;
}
.pill--ok { background: #dcfce7; color: #14532d; }
.pill--warn { background: #fef3c7; color: #92400e; }
.pill--danger { background: #fee2e2; color: #991b1b; }
.pill--muted { background: var(--muted-bg); color: var(--mute); }
.msp-dropzone {
position: relative;
background: var(--surface);
border: 1.5px dashed #cbd5e1;
border-radius: 14px;
padding: 36px 28px;
text-align: center;
cursor: pointer;
transition: background-color .18s, border-color .18s, transform .18s;
overflow: hidden;
animation: msp-fade-in .5s ease .1s both;
}
.msp-dropzone::before {
content: "";
position: absolute; inset: 0;
background: radial-gradient(circle at 50% -10%, rgba(36, 144, 239, 0.06), transparent 55%);
pointer-events: none;
}
.msp-dropzone.is-dragover {
border-color: var(--primary);
background: rgba(36, 144, 239, 0.04);
transform: translateY(-1px);
}
.msp-dropzone__inner {
display: flex; flex-direction: column; align-items: center; gap: 10px;
position: relative; z-index: 1;
}
.msp-dropzone__orb {
width: 46px; height: 46px; border-radius: 50%;
background: linear-gradient(145deg, var(--primary) 0%, var(--primary-ink) 100%);
position: relative;
box-shadow: 0 4px 14px rgba(36, 144, 239, 0.25);
}
.msp-dropzone__orb::after {
content: "";
position: absolute;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: 14px; height: 14px;
border-top: 2.5px solid #fff; border-left: 2.5px solid #fff;
border-radius: 2px;
transform-origin: center;
transform: translate(-50%, -44%) rotate(45deg);
}
.msp-dropzone__copy { display: flex; flex-direction: column; gap: 4px; }
.msp-dropzone__label {
font-size: 18px; font-weight: 600; color: var(--ink);
letter-spacing: -0.01em;
}
.msp-dropzone__hint { font-size: 13px; color: var(--ink-soft); }
.msp-dropzone__browse {
margin-top: 6px;
font-weight: 500;
}
.msp-cards {
display: flex; flex-direction: column; gap: 10px;
margin-top: 18px;
}
.msp-card {
position: relative;
display: grid;
grid-template-columns: 40px 1fr;
gap: 14px;
padding: 16px 18px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 10px;
animation: msp-fade-in .3s ease both;
}
.msp-card__badge {
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
background: var(--muted-bg);
border-radius: 8px;
color: var(--ink-soft);
flex: none;
}
.msp-card__head {
display: flex; gap: 8px; align-items: baseline;
margin-bottom: 6px;
}
.msp-card__name { font-size: 15px; font-weight: 600; margin: 0; color: var(--ink); letter-spacing: -0.01em; }
.msp-card__size { font-size: 12px; color: var(--mute); }
.msp-card__state { font-size: 13px; color: var(--ink-soft); display: flex; align-items: center; gap: 6px; }
.msp-card__state .spinner {
width: 10px; height: 10px; border-radius: 50%;
border: 1.5px solid var(--line);
border-top-color: var(--primary);
animation: msp-spin .8s linear infinite;
display: inline-block;
}
.msp-card__state em { font-style: normal; color: var(--mute); }
.msp-card__state strong { font-weight: 600; color: var(--ink); }
.msp-card__state .check {
color: #fff;
background: var(--ok);
width: 16px; height: 16px;
border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 10px;
}
.msp-card__state .cross {
color: var(--danger);
width: 16px; height: 16px;
border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
background: #fee2e2;
font-size: 11px;
font-weight: 700;
}
.msp-card__state .cross--warn { color: var(--warn); background: #fef3c7; }
.msp-card--matched { border-left: 3px solid var(--ok); padding-left: 16px; }
.msp-card--unmatched { border-left: 3px solid var(--warn); padding-left: 16px; }
.msp-card--error { border-left: 3px solid var(--danger); padding-left: 16px; }
.msp-card__stats {
display: flex; flex-wrap: wrap; gap: 6px;
margin-top: 10px;
}
.msp-chip {
display: inline-flex; gap: 6px; align-items: baseline;
padding: 4px 10px;
background: var(--muted-bg);
border-radius: 7px;
font-size: 12.5px;
}
.msp-chip__label { color: var(--mute); text-transform: lowercase; }
.msp-chip__value { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }
.msp-chip--warn { background: #fef3c7; }
.msp-chip--warn .msp-chip__label,
.msp-chip--warn .msp-chip__value { color: #92400e; }
.msp-card__warn {
font-size: 12.5px;
color: var(--warn);
background: #fffbeb;
padding: 8px 10px;
border-radius: 6px;
margin-top: 10px;
}
.msp-card__error {
font-size: 12.5px;
color: var(--danger);
background: #fef2f2;
padding: 8px 10px;
border-radius: 6px;
margin-top: 10px;
white-space: pre-wrap;
}
.msp-card__actions {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
margin-top: 12px;
}
.msp-card__actions .msp-input {
width: auto; min-width: 260px;
}
.btn-open-assistant .arr { margin-left: 4px; }
.msp-recent { margin-top: 32px; }
.msp-section-head {
display: flex; align-items: baseline; justify-content: space-between;
margin-bottom: 12px;
}
.msp-section-title {
font-size: 14px;
text-transform: uppercase;
letter-spacing: .1em;
color: var(--mute);
font-weight: 600;
margin: 0;
}
.msp-section-link {
font-size: 13px;
color: var(--ink-soft);
text-decoration: none;
}
.msp-section-link:hover { color: var(--primary); }
.msp-recent__list {
display: flex; flex-direction: column;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
background: var(--surface);
}
.msp-recent__row {
display: grid;
grid-template-columns: 130px 1fr 1.3fr 1.6fr auto;
gap: 18px;
padding: 12px 16px;
border-bottom: 1px solid var(--line);
text-decoration: none;
color: var(--ink);
font-size: 13px;
transition: background-color .12s;
}
.msp-recent__row:last-child { border-bottom: 0; }
.msp-recent__row:hover { background: var(--surface-alt); }
.msp-recent__status { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; }
.msp-recent__name { color: var(--ink-soft); font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px; }
.msp-recent__profile { color: var(--ink-soft); }
.msp-recent__nums { color: var(--ink-soft); font-variant-numeric: tabular-nums; }
.msp-recent__nums strong { color: var(--ink); font-weight: 600; }
.msp-recent__nums .danger { color: var(--danger); }
.msp-recent__time { color: var(--mute); font-size: 12px; justify-self: end; }
.msp-recent__empty {
padding: 26px; text-align: center;
color: var(--mute); font-size: 13px;
}
.dot {
display: inline-block;
width: 7px; height: 7px;
border-radius: 50%;
}
.dot--ok { background: var(--ok); }
.dot--warn { background: var(--warn); }
.dot--danger { background: var(--danger); }
.dot--accent { background: var(--primary); }
.dot--muted { background: var(--mute); }
.muted { color: var(--mute); }
@keyframes msp-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes msp-shimmer {
from { background-position: 0 0; }
to { background-position: -200% 0; }
}
@keyframes msp-spin {
from { transform: rotate(0); }
to { transform: rotate(360deg); }
}
`;
root.appendChild(style);
}