diff --git a/invoice_az/background_tasks.py b/invoice_az/background_tasks.py
new file mode 100644
index 0000000..bcaaa4d
--- /dev/null
+++ b/invoice_az/background_tasks.py
@@ -0,0 +1,173 @@
+# ======= GLOBAL BACKGROUND TASK REGISTRY =======
+#
+# A lightweight, per-user registry of in-flight background jobs, backed by the
+# Redis cache. It powers the desk-wide "Background Tasks" widget (bottom-right):
+# every long-running worker writes a progress snapshot here, the widget reads it
+# back and can request cancellation.
+#
+# Design notes:
+# * Keyed by USER. `get_active_background_tasks()` / `cancel_background_task()`
+# derive the user from `frappe.session.user` — the client never passes a user,
+# so one user can neither see nor cancel another user's jobs.
+# * The registry is the source of truth (survives page reloads). Realtime pings
+# are only a "something changed, re-fetch" hint — anything published before the
+# client subscribes is lost, so the widget ALSO polls this on a timer.
+# * Cancellation is best-effort: the worker checks `is_cancel_requested()`
+# between steps and bails out after finishing the current step.
+# * Generalising this to the other bulk imports (sales/purchase/vat/amas) is
+# ~10 lines each: new_job_id() -> register_task -> update_task ->
+# is_cancel_requested -> finish_task.
+
+import uuid
+
+import frappe
+
+REALTIME_EVENT = "background_tasks_changed"
+
+_REGISTRY_TTL = 7200 # the per-user registry key self-expires after 2h
+_CANCEL_TTL = 3600
+
+
+# ======= INTERNAL HELPERS =======
+
+def _registry_key(user):
+ return f"invoice_az:bg_tasks:{user}"
+
+
+def _cancel_key(job_id):
+ return f"invoice_az:bg_cancel:{job_id}"
+
+
+def _read(user):
+ return frappe.cache().get_value(_registry_key(user)) or {}
+
+
+def _write(user, tasks):
+ frappe.cache().set_value(_registry_key(user), tasks, expires_in_sec=_REGISTRY_TTL)
+
+
+def _ping(user, payload=None):
+ """Hint the widget to re-fetch. `payload` may carry a finished task snapshot
+ so the widget can flash its final state before dropping it."""
+ frappe.publish_realtime(REALTIME_EVENT, payload or {}, user=user)
+
+
+# ======= PUBLIC WORKER-SIDE API =======
+
+def new_job_id():
+ return uuid.uuid4().hex
+
+
+def register_task(job_id, user, feature, label, total=0, cancellable=True):
+ """Add a task to the user's registry in the `running` state."""
+ if not job_id:
+ return
+ tasks = _read(user)
+ tasks[job_id] = {
+ "job_id": job_id,
+ "feature": feature,
+ "label": label,
+ "current": 0,
+ "total": total or 0,
+ "percent": 0,
+ "status": "running", # running | cancelling | done | error | cancelled
+ "cancellable": bool(cancellable),
+ "message": "",
+ }
+ _write(user, tasks)
+ _ping(user)
+
+
+def update_task(job_id, user, current=None, total=None, percent=None, message=None):
+ """Update progress for a running task. Recomputes `percent` from
+ current/total unless an explicit `percent` is given."""
+ if not job_id:
+ return
+ tasks = _read(user)
+ task = tasks.get(job_id)
+ if not task:
+ return
+ if total is not None:
+ task["total"] = total or 0
+ if current is not None:
+ task["current"] = current
+ if message is not None:
+ task["message"] = message
+ if percent is not None:
+ task["percent"] = max(0, min(100, int(percent)))
+ elif task.get("total"):
+ task["percent"] = max(0, min(100, int(round(task["current"] / task["total"] * 100))))
+ tasks[job_id] = task
+ _write(user, tasks)
+ _ping(user)
+
+
+def finish_task(job_id, user, status="done", message=None):
+ """Remove the task from the registry and flash its final state to the widget.
+
+ Removing it immediately keeps the poll-based fallback consistent: a task that
+ is gone from the registry is simply gone from the widget's list, so a lost
+ realtime ping can't leave a stale "running" row behind."""
+ if not job_id:
+ return
+ tasks = _read(user)
+ task = tasks.pop(job_id, None)
+ _write(user, tasks)
+ frappe.cache().delete_value(_cancel_key(job_id))
+ if task:
+ task["status"] = status
+ if message is not None:
+ task["message"] = message
+ if status == "done":
+ task["percent"] = 100
+ _ping(user, {"finished": task})
+ else:
+ _ping(user)
+
+
+def request_cancel(job_id, user):
+ """Set the cancel flag for a task owned by `user`. Returns False if the task
+ is not in this user's registry (so cross-user cancellation is impossible)."""
+ tasks = _read(user)
+ task = tasks.get(job_id)
+ if not task:
+ return False
+ frappe.cache().set_value(_cancel_key(job_id), 1, expires_in_sec=_CANCEL_TTL)
+ task["status"] = "cancelling"
+ task["message"] = "Cancellation requested"
+ tasks[job_id] = task
+ _write(user, tasks)
+ _ping(user)
+ return True
+
+
+def is_cancel_requested(job_id):
+ """Fresh-from-Redis check the worker calls between steps.
+
+ CRITICAL: plain get_value() reads/writes frappe.local.cache, which pins the
+ first result (None) for the whole job — so a cancel flag set later by the web
+ request would never be seen. expires=True + use_local_cache=False forces a
+ real Redis read every call."""
+ return bool(
+ frappe.cache().get_value(_cancel_key(job_id), expires=True, use_local_cache=False)
+ )
+
+
+# ======= WHITELISTED CLIENT-SIDE API =======
+
+@frappe.whitelist()
+def get_active_background_tasks():
+ """Return the current user's in-flight tasks. Source of truth for the widget."""
+ return list(_read(frappe.session.user).values())
+
+
+@frappe.whitelist()
+def cancel_background_task(job_id):
+ """Best-effort cancel of one of the current user's tasks."""
+ user = frappe.session.user
+ if not request_cancel(job_id, user):
+ return {"success": False, "message": frappe._("Task not found or already finished.")}
+ return {
+ "success": True,
+ "message": frappe._("Cancellation requested. The job will stop after the current step."),
+ }
diff --git a/invoice_az/hooks.py b/invoice_az/hooks.py
index 7af745f..f12f03b 100644
--- a/invoice_az/hooks.py
+++ b/invoice_az/hooks.py
@@ -114,7 +114,7 @@ fixtures = []
# include js, css files in header of desk.html
# app_include_css = "/assets/invoice_az/css/invoice_az.css"
-# app_include_js = "/assets/invoice_az/js/invoice_az.js"
+app_include_js = "background_tasks.bundle.js"
# include js, css files in header of web template
# web_include_css = "/assets/invoice_az/css/invoice_az.css"
diff --git a/invoice_az/public/js/background_tasks.bundle.js b/invoice_az/public/js/background_tasks.bundle.js
new file mode 100644
index 0000000..0f7c9b6
--- /dev/null
+++ b/invoice_az/public/js/background_tasks.bundle.js
@@ -0,0 +1,270 @@
+// ======= GLOBAL BACKGROUND TASKS WIDGET =======
+//
+// A desk-wide, bottom-right widget that shows the current user's in-flight
+// background jobs (data loading, imports, ...) with progress and per-task cancel.
+//
+// Behaviour (per product spec):
+// * Appears automatically while any task is running.
+// * Collapsible: a header "Background Tasks (N)" expands to the task list.
+// * Each row has a progress bar + a red cancel button (with confirmation).
+// * The header X hides the whole widget until the page is reloaded
+// (dismissal is in-memory only — a reload brings it back if jobs run).
+// * Disappears once no tasks remain.
+//
+// Reliability: realtime `background_tasks_changed` pings drive instant updates,
+// but a poll on a timer is the source of truth (realtime events published before
+// we subscribed are lost forever — same lesson as the AMAS import bar).
+
+frappe.provide("invoice_az.bg_tasks");
+
+(function () {
+ const POLL_MS = 3000;
+ const FLASH_MS = 6000; // how long a finished row lingers before fading out
+ const FETCH_METHOD = "invoice_az.background_tasks.get_active_background_tasks";
+ const CANCEL_METHOD = "invoice_az.background_tasks.cancel_background_task";
+ const EVENT = "background_tasks_changed";
+
+ const state = {
+ started: false,
+ tasks: {}, // job_id -> running task snapshot (from the registry)
+ finished: {}, // job_id -> finished task snapshot (flashing, client-only)
+ dismissed: false, // header X — resets on reload
+ expanded: true,
+ $root: null,
+ pollTimer: null,
+ };
+
+ // ---------- styles ----------
+ function injectStyles() {
+ if (document.getElementById("iaz-bg-tasks-style")) return;
+ const css = `
+.iaz-bg-tasks {
+ position: fixed; right: 16px; bottom: 16px; z-index: 1030;
+ width: 340px; max-width: calc(100vw - 32px);
+ background: var(--card-bg, #fff); color: var(--text-color, #1f272e);
+ border: 1px solid var(--border-color, #d1d8dd); border-radius: 10px;
+ box-shadow: 0 6px 24px rgba(0,0,0,.18); font-size: 12px; overflow: hidden;
+}
+.iaz-bg-tasks__header {
+ display: flex; align-items: center; gap: 8px;
+ padding: 9px 12px; cursor: pointer; user-select: none;
+ background: var(--subtle-fg, #f4f5f6); border-bottom: 1px solid var(--border-color, #e2e6e9);
+ font-weight: 600;
+}
+.iaz-bg-tasks__title { flex: 1; }
+.iaz-bg-tasks__chev { opacity: .7; }
+.iaz-bg-tasks__close {
+ cursor: pointer; opacity: .6; padding: 0 4px; font-size: 15px; line-height: 1;
+}
+.iaz-bg-tasks__close:hover { opacity: 1; }
+.iaz-bg-tasks__body { max-height: 50vh; overflow-y: auto; }
+.iaz-bg-tasks__row { padding: 10px 12px; border-bottom: 1px solid var(--border-color, #f0f2f4); }
+.iaz-bg-tasks__row:last-child { border-bottom: none; }
+.iaz-bg-tasks__rowtop { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
+.iaz-bg-tasks__label { flex: 1; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.iaz-bg-tasks__pct { opacity: .7; font-variant-numeric: tabular-nums; }
+.iaz-bg-tasks__cancel {
+ cursor: pointer; border: none; background: transparent; color: #d9534f;
+ font-size: 14px; line-height: 1; padding: 0 2px;
+}
+.iaz-bg-tasks__cancel[disabled] { opacity: .35; cursor: default; }
+.iaz-bg-tasks__bar { position: relative; width: 100%; height: 6px; border-radius: 4px; background: var(--border-color, #e2e6e9); overflow: hidden; }
+.iaz-bg-tasks__fill { position: absolute; left: 0; top: 0; height: 100%; width: 0; background: var(--primary, #2490ef); transition: width .25s ease; }
+.iaz-bg-tasks__row--done .iaz-bg-tasks__fill { background: #28a745; }
+.iaz-bg-tasks__row--cancelled .iaz-bg-tasks__fill,
+.iaz-bg-tasks__row--error .iaz-bg-tasks__fill { background: #d9534f; }
+.iaz-bg-tasks__msg { margin-top: 4px; opacity: .6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+`;
+ const style = document.createElement("style");
+ style.id = "iaz-bg-tasks-style";
+ style.textContent = css;
+ document.head.appendChild(style);
+ }
+
+ // ---------- data ----------
+ function refresh() {
+ frappe.call({
+ method: FETCH_METHOD,
+ callback: function (r) {
+ const list = (r && r.message) || [];
+ const incoming = {};
+ list.forEach(function (t) {
+ if (t && t.job_id) incoming[t.job_id] = t;
+ });
+ // A task we knew as running that vanished from the registry (e.g. a
+ // lost "finished" ping) is treated as completed — flash then drop.
+ Object.keys(state.tasks).forEach(function (jid) {
+ if (!incoming[jid] && !state.finished[jid]) {
+ const prev = state.tasks[jid];
+ flashFinished(Object.assign({}, prev, {
+ status: prev.status === "cancelling" ? "cancelled" : "done",
+ percent: prev.status === "cancelling" ? prev.percent : 100,
+ }));
+ }
+ });
+ state.tasks = incoming;
+ render();
+ },
+ error: function () {}, // silent — the poller must never nag the user
+ });
+ }
+
+ function onPing(data) {
+ if (data && data.finished && data.finished.job_id) {
+ delete state.tasks[data.finished.job_id];
+ flashFinished(data.finished);
+ }
+ refresh();
+ }
+
+ function flashFinished(task) {
+ if (!task || !task.job_id) return;
+ state.finished[task.job_id] = task;
+ render();
+ setTimeout(function () {
+ delete state.finished[task.job_id];
+ render();
+ }, FLASH_MS);
+ }
+
+ function cancelTask(jobId, label) {
+ frappe.confirm(
+ __("Cancel background task: {0}?", [label || __("this task")]) +
+ "
" +
+ __("Data already loaded will be kept; the rest will be skipped."),
+ function () {
+ frappe.call({
+ method: CANCEL_METHOD,
+ args: { job_id: jobId },
+ callback: function (r) {
+ const m = (r && r.message) || {};
+ frappe.show_alert(
+ {
+ message: m.message || __("Cancellation requested."),
+ indicator: m.success ? "orange" : "red",
+ },
+ 6
+ );
+ refresh();
+ },
+ });
+ }
+ );
+ }
+
+ // ---------- view ----------
+ function removeRoot() {
+ if (state.$root) {
+ state.$root.remove();
+ state.$root = null;
+ }
+ }
+
+ function ensureRoot() {
+ if (state.$root) return;
+ state.$root = $('