jey_wizard/jey_wizard/public/js/jey_setup.js

1195 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Loaded via setup_wizard_requires hook. At the time this runs:
// - frappe/setup_wizard.js has already defined frappe.setup.SetupWizard and the default
// slides_settings + the `frappe.pages["setup-wizard"].on_page_load` handler.
// - The handler called frappe.require([...this file...], cb). After we finish loading,
// cb runs and does `new frappe.setup.SetupWizard(wizard_settings)`.
// So overriding the class here replaces the default UI entirely for this pageload.
frappe.provide("jey_wizard");
// Bump this string in every commit that changes wizard code. Displayed in the badge so
// we can tell at a glance which version is actually running on a given machine. Kept in
// sync with __version__ in jey_wizard/__init__.py.
const JEY_WIZARD_VERSION = "0.1.5";
// Wipe Frappe + ERPNext default slides so their `before_load`/`after_load` listeners
// don't try to mutate a wizard that isn't slide-based anymore.
frappe.setup.slides = [];
frappe.setup.slides_settings = [];
frappe.setup.events = {};
// Asan polling: 20 attempts × 5s = ~100s window for the user to confirm on phone.
const ASAN_POLL_INTERVAL_MS = 5000;
const ASAN_POLL_MAX_ATTEMPTS = 20;
// MyGovID's /asanSignLogin is a server-held long-poll (up to 120s). We let the call
// run for 130s before giving up client-side.
const MYGOVID_TIMEOUT_MS = 130000;
// Language code -> full name. load_messages and Frappe's setup_complete both accept
// the full name form; the language step uses codes internally so the field is compact.
const LANG_NAME = {
en: "English",
az: "Azərbaycan dili",
ru: "Русский",
};
frappe.setup.SetupWizard = class JeySetupWizard {
constructor(settings = {}) {
this.parent = settings.parent;
this.values = {};
const year = new Date().getFullYear();
this.data = {
language: "az",
asan_prefix: "",
asan_local_number: "",
asan_phone: "",
asan_user_id: "",
asan_login_name: "",
asan_voen: "",
company_name: "",
company_abbr: "",
company_abbr_user_edited: false,
fy_start_date: `${year}-01-01`,
fy_end_date: `${year}-12-31`,
valuation_method: "FIFO",
// AMAS
amas_skipped: false,
amas_selected_employees: [],
amas_create_designation: 1,
amas_organization_name: "",
};
// Sub-state machines per step. `input` / `intro` is the always-safe fallback
// that Back can return to without worrying about dangling network state.
this.asan_state = "input";
this.asan_certificates = [];
this.asan_bearer_token = "";
this.asan_poll_attempts = 0;
this.asan_poll_timer = null;
this.amas_state = "intro";
this.amas_accounts = [];
this.amas_employees = [];
this.amas_existing_map = {};
this.amas_verification_code = "";
this.current_step = 0;
this.steps = ["language", "asan", "employees", "company", "confirm"];
this.render();
// Session arrives in whatever language Frappe booted in (usually English for
// the first-run Administrator). Pull the default language's translations
// immediately so every subsequent render runs in that language without the
// user having to re-pick it.
const bootLang = (frappe.boot.lang || "en").split("-")[0];
if (bootLang !== this.data.language) {
this.change_language(this.data.language, /* force */ true);
}
}
// Frappe's on_page_show calls frappe.wizard.show_slide — no-op for us.
show_slide() {}
render() {
const $parent = $(this.parent).empty();
this.$wrap = $(`
<div class="jey-wizard" style="max-width:720px;margin:80px auto;padding:32px;font-family:system-ui,sans-serif;position:relative">
<div class="jey-version" style="position:absolute;top:8px;right:8px;background:#222;color:#fff;padding:4px 10px;border-radius:4px;font-size:12px;font-weight:600">v${JEY_WIZARD_VERSION}</div>
<h1 style="margin-bottom:8px">Jey Setup</h1>
<div class="jey-progress" style="color:#888;margin-bottom:24px"></div>
<div class="jey-body"></div>
<div class="jey-nav" style="margin-top:24px;display:flex;gap:8px;justify-content:flex-end"></div>
<div class="jey-status" style="margin-top:16px;color:#c00"></div>
<style>@keyframes jey-spin { to { transform: rotate(360deg) } }</style>
</div>
`).appendTo($parent);
this.show_current_step();
}
show_current_step() {
this.stop_polling();
const step = this.steps[this.current_step];
const $body = this.$wrap.find(".jey-body").empty();
const $nav = this.$wrap.find(".jey-nav").empty();
this.$wrap.find(".jey-progress").text(
__("Step {0} of {1}", [this.current_step + 1, this.steps.length])
);
this.$wrap.find(".jey-status").empty();
if (step === "language") this.render_language($body);
else if (step === "asan") this.render_asan($body);
else if (step === "employees") this.render_employees($body);
else if (step === "company") this.render_company($body);
else if (step === "confirm") this.render_confirm($body);
this.render_nav($nav, step);
}
// ---------- step renderers ----------
render_language($body) {
$body.html(`
<h3 style="margin-bottom:16px">${__("Language")}</h3>
<label style="display:block;margin-bottom:8px">${__("Wizard & site language")}</label>
<select class="jey-language form-control" style="max-width:280px">
<option value="az">Azərbaycan</option>
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
`);
$body.find(".jey-language").val(this.data.language);
// Apply language immediately on change so subsequent screens pick it up.
$body.find(".jey-language").on("change", (e) => {
const code = $(e.currentTarget).val();
this.change_language(code);
});
}
render_asan($body) {
if (this.asan_state === "input") {
// +994 is prefixed silently on submit — we don't render it in the UI
// because it's the only possibility for Azerbaijani mobile numbers and
// a static label clutters the form. The visible parts are the carrier
// prefix (dropdown) and the 7-digit local number.
const prefixOptions = ["50", "51", "10", "55", "60", "70", "77", "99"]
.map((p) => `<option value="${p}">0${p}</option>`)
.join("");
$body.html(`
<h3 style="margin-bottom:16px">${__("Asan Imza authentication")}</h3>
<div style="margin-bottom:12px">
<label style="display:block;margin-bottom:4px">${__("Phone number")}</label>
<div style="display:flex;align-items:center;gap:8px;max-width:320px">
<select class="jey-asan-prefix form-control" style="width:90px;border:1px solid #bcc2ca;border-radius:4px;padding:6px 8px;background:#fff">
<option value="">${__("Prefix")}</option>
${prefixOptions}
</select>
<input type="text" inputmode="numeric" maxlength="7" class="jey-asan-phone form-control" style="flex:1" placeholder="${__("Enter number")}" />
</div>
</div>
<div style="margin-bottom:12px">
<label style="display:block;margin-bottom:4px">${__("User ID")}</label>
<input type="text" class="jey-asan-userid form-control" style="max-width:280px" placeholder="${__("ASAN ID")}" />
</div>
<button class="btn btn-primary jey-asan-auth" style="margin-top:8px">${__("Send request to Asan Imza")}</button>
`);
$body.find(".jey-asan-prefix").val(this.data.asan_prefix || "");
$body.find(".jey-asan-phone").val(this.data.asan_local_number || "");
$body.find(".jey-asan-userid").val(this.data.asan_user_id);
$body.find(".jey-asan-phone").on("input", (e) => {
// Strip anything that isn't a digit as the user types.
const cleaned = String($(e.currentTarget).val() || "").replace(/\D/g, "").slice(0, 7);
$(e.currentTarget).val(cleaned);
});
$body.find(".jey-asan-auth").on("click", () => this.start_asan_auth());
} else if (this.asan_state === "polling") {
const code = this.asan_verification_code || "";
$body.html(`
<h3 style="margin-bottom:16px">${__("Confirm on your phone")}</h3>
<p>${__("Open the Asan Imza app on your phone and confirm the request.")}</p>
${code
? `<div style="margin:16px 0;padding:14px;background:#f7f7f7;border-radius:6px;text-align:center">
<div style="color:#666;font-size:12px;margin-bottom:6px">${__("Verification code")}</div>
<div style="font-size:28px;letter-spacing:4px;font-weight:600">${frappe.utils.escape_html(String(code))}</div>
</div>`
: ""}
<div style="display:flex;align-items:center;gap:12px;margin-top:16px">
<div class="jey-spinner" style="width:18px;height:18px;border:3px solid #ddd;border-top-color:#444;border-radius:50%;animation:jey-spin 0.8s linear infinite"></div>
<div class="jey-asan-poll-status" style="color:#666">${__("Waiting for confirmation...")}</div>
</div>
`);
} else if (this.asan_state === "certs") {
const rows = (this.asan_certificates || []).map((c, i) => {
const legal = c.legalInfo || {};
const name = legal.name || "";
const voen = legal.voen || legal.tin || "";
const liquidated = c.liquidated ? ` <i style='color:#c00'>(${__("liquidated")})</i>` : "";
return `
<tr>
<td style="padding:8px;border-bottom:1px solid #eee">${frappe.utils.escape_html(name)}${liquidated}</td>
<td style="padding:8px;border-bottom:1px solid #eee;font-family:monospace">${frappe.utils.escape_html(voen)}</td>
<td style="padding:8px;border-bottom:1px solid #eee;text-align:right">
<button class="btn btn-sm btn-primary jey-cert-pick" data-idx="${i}">${__("Select")}</button>
</td>
</tr>
`;
}).join("");
$body.html(`
<h3 style="margin-bottom:16px">${__("Select your company")}</h3>
${rows
? `<table style="width:100%;border-collapse:collapse">
<thead><tr>
<th style="text-align:left;padding:8px;border-bottom:2px solid #ccc">${__("Company")}</th>
<th style="text-align:left;padding:8px;border-bottom:2px solid #ccc">${__("VÖEN")}</th>
<th style="padding:8px;border-bottom:2px solid #ccc"></th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`
: `<p style="color:#c00">${__("No legal-entity certificates available on this Asan ID. Use a phone/ID linked to a company.")}</p>`}
`);
$body.find(".jey-cert-pick").on("click", (e) => {
const idx = parseInt($(e.currentTarget).attr("data-idx"), 10);
this.confirm_and_pick_certificate(idx);
});
} else if (this.asan_state === "fetching") {
$body.html(`
<h3 style="margin-bottom:16px">${__("Loading data from tax portal...")}</h3>
<p style="color:#666">${__("Pulling objects, cash registers, POS terminals, bank accounts, obligation pacts and presented certificates. This usually takes 10-30 seconds.")}</p>
<div style="display:flex;align-items:center;gap:12px;margin-top:20px">
<div class="jey-spinner" style="width:18px;height:18px;border:3px solid #ddd;border-top-color:#444;border-radius:50%;animation:jey-spin 0.8s linear infinite"></div>
<div style="color:#666">${__("Fetching...")}</div>
</div>
`);
} else if (this.asan_state === "done") {
const summary = this.asan_fetch_summary || {};
const errors = this.asan_fetch_errors || {};
const labels = {
objects_json: __("Objects"),
cash_registers_json: __("Cash registers"),
pos_terminals_json: __("POS terminals"),
bank_accounts_json: __("Bank accounts"),
obligation_pacts_json: __("Obligation pacts"),
presented_certs_json: __("Presented certificates"),
};
const rows = Object.keys(labels).map((k) => {
const label = labels[k];
if (errors[k]) {
return `<li><b>${label}:</b> <span style="color:#c00">${__("failed")}</span> <span style="color:#888;font-size:12px">(${frappe.utils.escape_html(errors[k])})</span></li>`;
}
const n = summary[k];
if (n === undefined) {
return `<li><b>${label}:</b> <span style="color:#888">—</span></li>`;
}
return `<li><b>${label}:</b> ${n}</li>`;
}).join("");
$body.html(`
<h3 style="margin-bottom:16px;color:#080">✓ ${__("Authenticated & data loaded")}</h3>
<p>${__("Pulled from the tax portal:")}</p>
<ul style="line-height:1.8">
<li><b>${__("Company")}:</b> ${frappe.utils.escape_html(this.data.company_name)}</li>
<li><b>${__("VÖEN")}:</b> ${frappe.utils.escape_html(this.data.asan_voen)}</li>
</ul>
<div style="margin-top:16px;padding:12px;background:#f7f7f7;border-radius:6px">
<div style="font-weight:600;margin-bottom:6px">${__("Cached datasets")}</div>
<ul style="line-height:1.6;margin:0;padding-left:20px">${rows}</ul>
</div>
<div style="margin-top:16px;text-align:right">
<button class="btn btn-default btn-sm jey-change-company">${__("Wrong company? Change")}</button>
</div>
`);
$body.find(".jey-change-company").on("click", () => this.reset_company_selection());
}
}
render_employees($body) {
if (this.amas_state === "intro") {
const alreadyLoaded = (this.data.amas_selected_employees || []).length;
$body.html(`
<h3 style="margin-bottom:16px">${__("Load employees from ƏMAS")}</h3>
<p>${__("ƏMAS (e-social.gov.az) stores employment contracts. If your company has employees registered there, we can import them now.")}</p>
<div style="margin-top:12px;padding:12px;background:#fff8e1;border-left:3px solid #e0a800;border-radius:4px">
<b>${__("Heads up:")}</b> ${__("You will receive a second confirmation request on your phone from ASAN Sign / MyGovID. This is separate from the one you just approved for the tax portal.")}
</div>
<p style="color:#888;margin-top:12px">${__("This step is optional. Use Next to skip if your company has no employees or is not registered with ƏMAS.")}</p>
${alreadyLoaded
? `<div style="margin-top:12px;padding:10px;background:#e8f5e9;border-radius:4px">
${__("Already loaded:")} <b>${alreadyLoaded}</b> ${__("employee(s)")}.
</div>`
: ""}
<div style="margin-top:20px">
<button class="btn btn-primary jey-amas-load">${__("Load employees")}</button>
</div>
`);
$body.find(".jey-amas-load").on("click", () => this.amas_start_auth());
} else if (this.amas_state === "auth") {
const code = this.amas_verification_code || "";
$body.html(`
<h3 style="margin-bottom:16px">${__("Confirm ƏMAS request on your phone")}</h3>
<p>${__("A second confirmation request has been sent to your phone. Open ASAN Sign and approve it.")}</p>
${code
? `<div style="margin:16px 0;padding:14px;background:#f7f7f7;border-radius:6px;text-align:center">
<div style="color:#666;font-size:12px;margin-bottom:6px">${__("Verification code")}</div>
<div style="font-size:28px;letter-spacing:4px;font-weight:600">${frappe.utils.escape_html(String(code))}</div>
</div>`
: `<div style="color:#888;font-size:12px;margin-top:8px">${__("Retrieving verification code...")}</div>`}
<div style="display:flex;align-items:center;gap:12px;margin-top:16px">
<div class="jey-spinner" style="width:18px;height:18px;border:3px solid #ddd;border-top-color:#444;border-radius:50%;animation:jey-spin 0.8s linear infinite"></div>
<div style="color:#666">${__("Waiting for confirmation...")}</div>
</div>
`);
} else if (this.amas_state === "connecting") {
$body.html(`
<h3 style="margin-bottom:16px">${__("Connecting to ƏMAS...")}</h3>
<div style="display:flex;align-items:center;gap:12px;margin-top:16px">
<div class="jey-spinner" style="width:18px;height:18px;border:3px solid #ddd;border-top-color:#444;border-radius:50%;animation:jey-spin 0.8s linear infinite"></div>
<div style="color:#666">${__("Establishing session...")}</div>
</div>
`);
} else if (this.amas_state === "accounts") {
const rows = (this.amas_accounts || []).map((a, i) => {
const name = a.accountName || a.name || "";
const voen = a.accountNumber || a.voen || a.number || "";
const role = a.roleName || a.role || "";
return `
<tr>
<td style="padding:8px;border-bottom:1px solid #eee">${frappe.utils.escape_html(name)}</td>
<td style="padding:8px;border-bottom:1px solid #eee;font-family:monospace">${frappe.utils.escape_html(voen)}</td>
<td style="padding:8px;border-bottom:1px solid #eee">${frappe.utils.escape_html(role)}</td>
<td style="padding:8px;border-bottom:1px solid #eee;text-align:right">
<button class="btn btn-sm btn-primary jey-amas-acc-pick" data-idx="${i}">${__("Select")}</button>
</td>
</tr>
`;
}).join("");
$body.html(`
<h3 style="margin-bottom:16px">${__("Select organization")}</h3>
<p style="color:#888">${__("We could not auto-match by VÖEN. Pick the organization you want to import employees from.")}</p>
<table style="width:100%;border-collapse:collapse;margin-top:12px">
<thead><tr>
<th style="text-align:left;padding:8px;border-bottom:2px solid #ccc">${__("Organization")}</th>
<th style="text-align:left;padding:8px;border-bottom:2px solid #ccc">${__("VÖEN")}</th>
<th style="text-align:left;padding:8px;border-bottom:2px solid #ccc">${__("Role")}</th>
<th style="padding:8px;border-bottom:2px solid #ccc"></th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
`);
$body.find(".jey-amas-acc-pick").on("click", (e) => {
const idx = parseInt($(e.currentTarget).attr("data-idx"), 10);
this.amas_pick_account(idx);
});
} else if (this.amas_state === "loading") {
$body.html(`
<h3 style="margin-bottom:16px">${__("Loading employees...")}</h3>
<div style="display:flex;align-items:center;gap:12px;margin-top:16px">
<div class="jey-spinner" style="width:18px;height:18px;border:3px solid #ddd;border-top-color:#444;border-radius:50%;animation:jey-spin 0.8s linear infinite"></div>
<div style="color:#666">${__("Fetching from ƏMAS...")}</div>
</div>
`);
} else if (this.amas_state === "done") {
const n = (this.data.amas_selected_employees || []).length;
$body.html(`
<h3 style="margin-bottom:16px;color:#080">✓ ${__("Employees ready to import")}</h3>
<div style="padding:12px;background:#e8f5e9;border-radius:4px;margin-bottom:12px">
${__("Organization:")} <b>${frappe.utils.escape_html(this.data.amas_organization_name || "")}</b><br>
${__("Employees found:")} <b>${n}</b>
</div>
<p style="color:#666">${__("All employees will be imported into ƏMAS Employees and the standard Employee doctype after the wizard finishes.")}</p>
`);
// No extra buttons here — Back/Next in nav cover everything.
}
}
render_company($body) {
$body.html(`
<h3 style="margin-bottom:16px">${__("Company")}</h3>
<div style="margin-bottom:12px">
<label style="display:block;margin-bottom:4px">${__("Company name")}</label>
<input type="text" class="jey-company-name form-control" style="max-width:380px" />
${this.data.asan_voen
? `<div style="color:#888;font-size:12px;margin-top:6px">${__("VÖEN {0} (from Asan)", [frappe.utils.escape_html(this.data.asan_voen)])}</div>`
: ""}
</div>
<div style="margin-bottom:12px">
<label style="display:block;margin-bottom:4px">${__("Company abbreviation")}</label>
<input type="text" class="jey-company-abbr form-control" style="max-width:140px;text-transform:uppercase" maxlength="10" />
<div style="color:#888;font-size:12px;margin-top:6px">${__("Short code suffixed to account names. Auto-suggested from the company name — you can edit it.")}</div>
</div>
<h4 style="margin-top:20px;margin-bottom:10px">${__("Fiscal Year")}</h4>
<div style="display:flex;gap:16px;max-width:420px">
<div style="flex:1">
<label style="display:block;margin-bottom:4px">${__("Start date")}</label>
<input type="date" class="jey-fy-start form-control" />
</div>
<div style="flex:1">
<label style="display:block;margin-bottom:4px">${__("End date")}</label>
<input type="date" class="jey-fy-end form-control" />
</div>
</div>
<div style="color:#888;font-size:12px;margin-top:6px">${__("Default: current calendar year (Jan 1 Dec 31).")}</div>
<h4 style="margin-top:20px;margin-bottom:10px">${__("Stock Valuation")}</h4>
<div style="max-width:280px">
<label style="display:block;margin-bottom:4px">${__("Valuation method")}</label>
<select class="jey-valuation-method form-control">
<option value="FIFO">FIFO</option>
<option value="Moving Average">${__("Moving Average")}</option>
</select>
<div style="color:#888;font-size:12px;margin-top:6px">${__("Cannot be changed after the first stock entry.")}</div>
</div>
`);
$body.find(".jey-company-name").val(this.data.company_name);
$body.find(".jey-fy-start").val(this.data.fy_start_date);
$body.find(".jey-fy-end").val(this.data.fy_end_date);
$body.find(".jey-valuation-method").val(this.data.valuation_method || "FIFO");
// If user has never touched the abbr field, keep it in sync with the name.
// Once they edit it manually we stop auto-suggesting so their choice sticks.
const $nameEl = $body.find(".jey-company-name");
const $abbrEl = $body.find(".jey-company-abbr");
const fillAbbrDefault = () => {
if (!this.data.company_abbr_user_edited) {
const suggested = this.derive_abbr($nameEl.val() || "");
this.data.company_abbr = suggested;
$abbrEl.val(suggested);
}
};
$abbrEl.val(this.data.company_abbr || this.derive_abbr(this.data.company_name || ""));
$nameEl.on("input", fillAbbrDefault);
$abbrEl.on("input", (e) => {
this.data.company_abbr_user_edited = true;
this.data.company_abbr = ($(e.currentTarget).val() || "").toUpperCase().slice(0, 10);
$(e.currentTarget).val(this.data.company_abbr);
});
}
derive_abbr(rawName) {
// Normalise Azerbaijani company-form boilerplate before tokenising so the
// auto-suggest doesn't spell out each word of "Məhdud Məsuliyyətli Cəmiyyəti"
// as separate initials. Also strip every quote character — the stock
// ERPNext deriver chokes on them.
if (!rawName) return "";
let name = String(rawName).replace(/["“”„‟«»‘’‚‛'`´]/g, " ");
name = name.replace(/[ƏE]/gi, (ch) => (ch === ch.toUpperCase() ? "E" : "e"));
const rules = [
[/\bMEHDUD\s+MESULIYYETLI\s+CEMIYYETI\b/gi, "MMC"],
[/\bA[CÇ]IQ\s+SEHMDAR\s+CEMIYYETI\b/gi, "ASC"],
[/\bQAPALI\s+SEHMDAR\s+CEMIYYETI\b/gi, "QSC"],
[/\bSEHMDAR\s+CEMIYYETI\b/gi, "SC"],
[/\bKOOPERATIV\b/gi, "K"],
];
for (const [re, rep] of rules) name = name.replace(re, rep);
const parts = name.split(/\s+/).filter(Boolean);
if (parts.length === 0) return "";
if (parts.length === 1) return parts[0].slice(0, 3).toUpperCase();
return parts.slice(0, 4).map((p) => p[0]).join("").toUpperCase().slice(0, 10);
}
render_confirm($body) {
const empCount = (this.data.amas_selected_employees || []).length;
const empRow = this.data.amas_skipped
? __("Skipped")
: (empCount > 0 ? __("{0} selected", [empCount]) : __("None"));
$body.html(`
<h3>${__("Confirm")}</h3>
<ul style="line-height:1.8">
<li><b>${__("Language")}:</b> ${LANG_NAME[this.data.language] || this.data.language}</li>
<li><b>${__("Company")}:</b> ${frappe.utils.escape_html(this.data.company_name)} <span style="color:#888">(${frappe.utils.escape_html(this.data.company_abbr || "")})</span></li>
${this.data.asan_voen
? `<li><b>${__("VÖEN")}:</b> ${frappe.utils.escape_html(this.data.asan_voen)}</li>`
: ""}
<li><b>${__("Fiscal Year")}:</b> ${frappe.utils.escape_html(this.data.fy_start_date)}${frappe.utils.escape_html(this.data.fy_end_date)}</li>
<li><b>${__("Valuation method")}:</b> ${this.data.valuation_method === "Moving Average" ? __("Moving Average") : "FIFO"}</li>
<li><b>${__("ƏMAS employees")}:</b> ${empRow}</li>
</ul>
`);
}
render_nav($nav, step) {
// Back is available on every step except the very first (language). Within a
// multi-sub-state step, Back first rewinds the sub-state to its intro; only
// Back from intro jumps to the previous wizard step.
if (this.current_step > 0) {
$(`<button class="btn btn-default jey-back">${__("Back")}</button>`)
.appendTo($nav)
.on("click", () => this.prev());
}
// Standard Next: shown when the step has no internal flow-control, or when a
// multi-sub-state step is in its "terminal" sub-state.
let showStdNext = false;
const lastStep = this.current_step === this.steps.length - 1;
if (!lastStep) {
if (step === "asan") {
// Asan drives its own transitions. Only the terminal "done" sub-state
// surfaces a standard Next.
showStdNext = this.asan_state === "done";
} else if (step === "employees") {
// Next is visible on intro (acts as "skip this step") and on done
// (move on with the loaded selection). During auth/loading/etc it
// stays hidden so the flow can't be short-circuited mid-request.
showStdNext = this.amas_state === "intro" || this.amas_state === "done";
} else {
showStdNext = true;
}
}
if (showStdNext) {
$(`<button class="btn btn-primary jey-next">${__("Next")}</button>`)
.appendTo($nav)
.on("click", () => this.next());
}
if (lastStep) {
$(`<button class="btn btn-primary jey-finish">${__("Finish")}</button>`)
.appendTo($nav)
.on("click", () => this.finalize());
}
}
// ---------- step navigation ----------
capture_current() {
const step = this.steps[this.current_step];
if (step === "language") {
this.data.language = this.$wrap.find(".jey-language").val() || "az";
} else if (step === "company") {
const name = (this.$wrap.find(".jey-company-name").val() || "").trim();
if (!name) {
this.$wrap.find(".jey-status").text(__("Company name is required"));
return false;
}
this.data.company_name = name;
const abbrRaw = (this.$wrap.find(".jey-company-abbr").val() || "").toUpperCase().replace(/\s+/g, "");
const abbr = abbrRaw || this.derive_abbr(name);
if (!abbr) {
this.$wrap.find(".jey-status").text(__("Company abbreviation is required"));
return false;
}
if (!/^[A-Z0-9]{1,10}$/.test(abbr)) {
this.$wrap.find(".jey-status").text(__("Abbreviation must be 110 letters/digits (AZ, 09)."));
return false;
}
this.data.company_abbr = abbr;
const fyStart = (this.$wrap.find(".jey-fy-start").val() || "").trim();
const fyEnd = (this.$wrap.find(".jey-fy-end").val() || "").trim();
if (!fyStart || !fyEnd) {
this.$wrap.find(".jey-status").text(__("Fiscal year start and end dates are required"));
return false;
}
if (fyEnd < fyStart) {
this.$wrap.find(".jey-status").text(__("Fiscal year end date must be after the start date"));
return false;
}
this.data.fy_start_date = fyStart;
this.data.fy_end_date = fyEnd;
const vm = (this.$wrap.find(".jey-valuation-method").val() || "FIFO").trim();
this.data.valuation_method = vm === "Moving Average" ? "Moving Average" : "FIFO";
}
return true;
}
next() {
if (!this.capture_current()) return;
const step = this.steps[this.current_step];
// Skip-by-Next on employees/intro with no prior selection: clear any stale
// cache so materialize_after_setup doesn't re-import a previous run's data.
if (
step === "employees" &&
this.amas_state === "intro" &&
(this.data.amas_selected_employees || []).length === 0
) {
this.data.amas_skipped = true;
frappe.call({
method: "jey_wizard.amas.clear_amas_cache",
callback: () => {
this.current_step++;
this.show_current_step();
},
});
return;
}
this.current_step++;
this.show_current_step();
}
prev() {
const step = this.steps[this.current_step];
// If we're inside a sub-state of a multi-state step, Back returns to its intro
// rather than jumping to the previous wizard step. Lets the user re-try.
if (step === "asan" && this.asan_state !== "input" && this.asan_state !== "done") {
this.asan_state = "input";
this.show_current_step();
return;
}
if (step === "employees" && this.amas_state !== "intro" && this.amas_state !== "done") {
this.amas_state = "intro";
this.show_current_step();
return;
}
// Crossing a step boundary: leave sub-state as-is so the user sees whatever
// they completed before (e.g. "done" screen). They can still rewind with a
// second Back.
this.current_step--;
this.show_current_step();
}
// ---------- language ----------
change_language(code, force = false) {
if (!code) return;
if (!force && code === this.data.language) return;
const langName = LANG_NAME[code] || "English";
frappe._messages = {};
frappe.call({
method: "frappe.desk.page.setup_wizard.setup_wizard.load_messages",
args: { language: langName },
freeze: true,
freeze_message: __("Loading translations..."),
callback: () => {
this.data.language = code;
this.show_current_step();
},
});
}
// ---------- asan flow ----------
start_asan_auth() {
const prefix = (this.$wrap.find(".jey-asan-prefix").val() || "").trim();
const local = (this.$wrap.find(".jey-asan-phone").val() || "").replace(/\D/g, "");
const userId = (this.$wrap.find(".jey-asan-userid").val() || "").trim();
if (!prefix) {
this.$wrap.find(".jey-status").text(__("Select a phone prefix"));
return;
}
if (local.length !== 7) {
this.$wrap.find(".jey-status").text(__("Phone number must be 7 digits"));
return;
}
if (!userId) {
this.$wrap.find(".jey-status").text(__("User ID is required"));
return;
}
this.data.asan_prefix = prefix;
this.data.asan_local_number = local;
this.data.asan_phone = `+994${prefix}${local}`;
this.data.asan_user_id = userId;
const phone = this.data.asan_phone;
this.$wrap.find(".jey-status").empty();
frappe.call({
method: "jey_wizard.api.ensure_asan_login",
args: { phone, user_id: userId },
freeze: true,
freeze_message: __("Preparing..."),
callback: (r) => {
const m = r.message || {};
if (!m.name) {
this.$wrap.find(".jey-status").text(__("Failed to create Asan Login record"));
return;
}
this.data.asan_login_name = m.name;
this.trigger_asan_authentication();
},
});
}
trigger_asan_authentication() {
frappe.call({
method: "invoice_az.auth.handle_authentication",
args: { asan_login_name: this.data.asan_login_name },
freeze: true,
freeze_message: __("Sending request to Asan Imza..."),
callback: (r) => {
const m = r.message || {};
if (!m.success) {
this.$wrap.find(".jey-status").text(m.message || __("Failed to start authentication"));
return;
}
this.asan_bearer_token = m.bearer_token || "";
this.asan_verification_code = m.verification_code || "";
this.asan_state = "polling";
this.asan_poll_attempts = 0;
this.show_current_step();
this.poll_asan();
},
});
}
poll_asan() {
if (this.asan_poll_attempts >= ASAN_POLL_MAX_ATTEMPTS) {
this.$wrap.find(".jey-asan-poll-status").html(
`<span style="color:#c00">${__("Timeout — confirmation not received within ~100 seconds.")}</span>`
);
this.$wrap.find(".jey-spinner").remove();
this.$wrap.find(".jey-nav")
.empty()
.append(
$(`<button class="btn btn-default jey-back">${__("Back")}</button>`)
.on("click", () => this.prev())
)
.append(
$(`<button class="btn btn-primary jey-asan-restart">${__("Try again")}</button>`)
.on("click", () => {
this.asan_state = "input";
this.show_current_step();
})
);
return;
}
this.asan_poll_attempts += 1;
frappe.call({
method: "invoice_az.auth.poll_auth_status",
args: {
asan_login_name: this.data.asan_login_name,
bearer_token: this.asan_bearer_token,
},
callback: (r) => {
if (this.asan_state !== "polling") return;
const m = r.message || {};
if (m.authenticated) {
this.fetch_certificates();
} else {
this.asan_poll_timer = setTimeout(
() => this.poll_asan(),
ASAN_POLL_INTERVAL_MS
);
}
},
});
}
stop_polling() {
if (this.asan_poll_timer) {
clearTimeout(this.asan_poll_timer);
this.asan_poll_timer = null;
}
}
fetch_certificates() {
this.stop_polling();
this.$wrap.find(".jey-asan-poll-status").text(__("Loading certificates..."));
frappe.call({
method: "invoice_az.auth.get_auth_certificates",
args: { asan_login_name: this.data.asan_login_name },
callback: (r) => {
const m = r.message || {};
if (!m.success) {
this.$wrap.find(".jey-status").text(m.message || __("Failed to load certificates"));
return;
}
const all = m.certificates || [];
this.asan_certificates = all.filter((c) => c.taxpayerType === "legal");
this.asan_state = "certs";
this.show_current_step();
},
});
}
confirm_and_pick_certificate(idx) {
const cert = this.asan_certificates[idx];
if (!cert) return;
const legal = cert.legalInfo || {};
const certName = `${legal.name || ""} (${legal.voen || legal.tin || ""})`;
frappe.confirm(
__("Confirm selection: {0}?", [frappe.utils.escape_html(certName)]),
() => this.pick_certificate(idx),
() => {}
);
}
pick_certificate(idx) {
const cert = this.asan_certificates[idx];
if (!cert) return;
const legal = cert.legalInfo || {};
const certName = `${legal.name || ""} (${legal.voen || legal.tin || ""})`;
frappe.call({
method: "invoice_az.auth.select_certificate",
args: {
asan_login_name: this.data.asan_login_name,
certificate_data: cert,
certificate_name: certName,
},
freeze: true,
freeze_message: __("Selecting certificate..."),
callback: (r) => {
const m = r.message || {};
if (!m.success) {
this.$wrap.find(".jey-status").text(m.message || __("Failed to select certificate"));
return;
}
this.run_select_taxpayer();
},
});
}
run_select_taxpayer() {
frappe.call({
method: "invoice_az.auth.select_taxpayer",
args: { asan_login_name: this.data.asan_login_name },
freeze: true,
freeze_message: __("Choosing taxpayer & getting main token..."),
callback: (r) => {
const m = r.message || {};
if (!m.success) {
this.$wrap.find(".jey-status").text(m.message || __("Failed to choose taxpayer"));
return;
}
this.fetch_company_from_cert();
},
});
}
fetch_company_from_cert() {
frappe.call({
method: "jey_wizard.api.get_company_from_cert",
args: { asan_login_name: this.data.asan_login_name },
callback: (r) => {
const m = r.message || {};
this.data.company_name = m.company_name || this.data.company_name;
this.data.asan_voen = m.voen || "";
this.asan_state = "fetching";
this.show_current_step();
this.fetch_etaxes_data();
},
});
}
reset_company_selection() {
// Reached from asan/done when the user realizes they picked the wrong cert.
// The e-taxes bearer_token is single-use per chooseTaxpayer call, so after
// the first select_taxpayer it can't be reused — the server returns 401.
// The only way to pick a different cert is to run a fresh auth round
// (another SMS + phone tap). We warn about that explicitly.
frappe.confirm(
__("This will clear loaded data and ƏMAS selection, and send a new confirmation request to your phone. Continue?"),
() => {
frappe.call({
method: "jey_wizard.api.reset_company_selection",
args: { asan_login_name: this.data.asan_login_name },
freeze: true,
freeze_message: __("Clearing..."),
callback: (r) => {
const m = r.message || {};
if (!m.ok) {
this.$wrap.find(".jey-status").text(__("Failed to clear data."));
return;
}
this.data.company_name = "";
this.data.asan_voen = "";
this.data.amas_selected_employees = [];
this.data.amas_skipped = false;
this.data.amas_organization_name = "";
this.amas_employees = [];
this.amas_existing_map = {};
this.amas_state = "intro";
this.asan_fetch_summary = {};
this.asan_fetch_errors = {};
this.asan_certificates = [];
this.asan_bearer_token = "";
this.asan_verification_code = "";
// Kick off a fresh auth round. Phone and user_id are still on
// the Asan Login record, so handle_authentication works without
// re-prompting the user.
this.trigger_asan_authentication();
},
});
},
() => {}
);
}
fetch_etaxes_data() {
frappe.call({
method: "jey_wizard.etaxes.fetch_all_etaxes",
callback: (r) => {
const m = r.message || {};
this.asan_fetch_summary = m.summary || {};
this.asan_fetch_errors = m.errors || {};
this.asan_state = "done";
this.show_current_step();
},
error: () => {
this.asan_fetch_summary = {};
this.asan_fetch_errors = { all: __("fetch call failed — see server logs") };
this.asan_state = "done";
this.show_current_step();
},
});
}
// ---------- AMAS flow ----------
amas_start_auth() {
if (!this.data.asan_login_name) {
this.$wrap.find(".jey-status").text(__("Finish Asan Imza authentication first."));
return;
}
this.data.amas_skipped = false;
this.amas_verification_code = "";
this.amas_state = "auth";
this.show_current_step();
// Long-polling: server holds the call until user confirms on phone (~120s).
frappe.call({
method: "invoice_az.invoice_az.doctype.asan_login.asan_login.start_mygovid_login",
args: { asan_login_name: this.data.asan_login_name },
timeout: MYGOVID_TIMEOUT_MS,
callback: (r) => {
if (this.amas_state !== "auth") return;
const m = r.message || {};
if (!m.success) {
this.$wrap.find(".jey-status").text(m.message || __("MyGovID authentication failed."));
this.amas_state = "intro";
this.show_current_step();
return;
}
this.amas_connect();
},
error: () => {
if (this.amas_state !== "auth") return;
this.$wrap.find(".jey-status").text(__("MyGovID request timed out."));
this.amas_state = "intro";
this.show_current_step();
},
});
// Fetch verification code shortly after the server started the long-poll.
setTimeout(() => {
if (this.amas_state !== "auth") return;
frappe.call({
method: "invoice_az.invoice_az.doctype.asan_login.asan_login.get_mygovid_verification_code",
args: { asan_login_name: this.data.asan_login_name },
callback: (r) => {
if (this.amas_state !== "auth") return;
const m = r.message || {};
if (m.success && m.code) {
this.amas_verification_code = m.code;
this.show_current_step();
}
},
});
}, 800);
}
amas_connect() {
this.amas_state = "connecting";
this.show_current_step();
frappe.call({
method: "invoice_az.amas_api.connect_amas",
args: { asan_login_name: this.data.asan_login_name },
callback: (r) => {
const m = r.message || {};
if (!m.success) {
this.$wrap.find(".jey-status").text(m.message || __("Failed to connect to ƏMAS."));
this.amas_state = "intro";
this.show_current_step();
return;
}
this.amas_accounts = m.accounts || [];
// Auto-pick by VOEN match with the e-taxes cert; fall back to manual list.
const targetVoen = String(this.data.asan_voen || "").trim();
let matchIdx = -1;
if (targetVoen) {
matchIdx = this.amas_accounts.findIndex((a) => {
const voen = String(a.accountNumber || a.voen || a.number || "").trim();
return voen && voen === targetVoen;
});
}
if (matchIdx >= 0) {
this.amas_pick_account(matchIdx);
} else if (this.amas_accounts.length === 1) {
this.amas_pick_account(0);
} else if (this.amas_accounts.length === 0) {
this.$wrap.find(".jey-status").text(__("No organizations found in ƏMAS for this account."));
this.amas_state = "intro";
this.show_current_step();
} else {
this.amas_state = "accounts";
this.show_current_step();
}
},
});
}
amas_pick_account(idx) {
const acc = this.amas_accounts[idx];
if (!acc) return;
const oid = acc.accountOid || acc.oid || "";
const name = acc.accountName || acc.name || "";
const number = acc.accountNumber || acc.voen || acc.number || "";
const role = acc.roleName || acc.role || "chairman";
this.amas_state = "connecting";
this.show_current_step();
frappe.call({
method: "invoice_az.amas_api.change_amas_account",
args: {
asan_login_name: this.data.asan_login_name,
account_oid: oid,
account_name: name,
account_number: number,
role_name: role,
},
callback: (r) => {
const m = r.message || {};
if (!m.success) {
this.$wrap.find(".jey-status").text(m.message || __("Failed to select organization."));
this.amas_state = "intro";
this.show_current_step();
return;
}
this.data.amas_organization_name = name;
this.amas_load_employees();
},
});
}
amas_load_employees() {
this.amas_state = "loading";
this.show_current_step();
frappe.call({
method: "invoice_az.amas_api.get_employees_report",
args: {
asan_login_name: this.data.asan_login_name,
offset: 0,
limit: 1000,
},
callback: (r) => {
const m = r.message || {};
if (!m.success) {
this.$wrap.find(".jey-status").text(m.message || __("Failed to fetch employees."));
this.amas_state = "intro";
this.show_current_step();
return;
}
const employees = m.employees || [];
if (employees.length === 0) {
this.$wrap.find(".jey-status").text(__("No employees found in ƏMAS for this organization."));
this.amas_state = "intro";
this.show_current_step();
return;
}
this.amas_employees = employees;
this.amas_commit_selection();
},
});
}
// Caches the full fetched list (user requested: import every active contract the
// report returned, no manual selection). Runs right after amas_load_employees.
amas_commit_selection() {
const all = this.amas_employees || [];
if (all.length === 0) {
this.amas_state = "intro";
this.show_current_step();
return;
}
const createDesignation = 1;
this.data.amas_create_designation = createDesignation;
frappe.call({
method: "jey_wizard.amas.cache_selected_employees",
args: {
employees_json: JSON.stringify(all),
create_designation: createDesignation,
},
callback: (r) => {
const m = r.message || {};
if (!m.ok) {
this.$wrap.find(".jey-status").text(__("Failed to cache selection."));
this.amas_state = "intro";
this.show_current_step();
return;
}
this.data.amas_selected_employees = all;
this.data.amas_skipped = false;
this.amas_state = "done";
this.show_current_step();
},
});
}
// ---------- finalization ----------
build_setup_args() {
const company = (this.data.company_name || "").trim() || "Test Company AZ";
// Prefer the user's (possibly edited) abbr captured in capture_current;
// fall back to the deriver if something bypassed validation.
const abbr = (
(this.data.company_abbr || "").toUpperCase().replace(/\s+/g, "") ||
this.derive_abbr(company) ||
"CO"
).slice(0, 10);
const admin = frappe.boot.user || {};
return {
language: LANG_NAME[this.data.language] || "English",
country: "Azerbaijan",
timezone: "Asia/Baku",
currency: "AZN",
enable_telemetry: 0,
full_name: admin.full_name || "Administrator",
email: admin.email || "admin@example.com",
password: "",
company_name: company,
company_abbr: abbr,
chart_of_accounts: "Azerbaijan Republic",
fy_start_date: this.data.fy_start_date,
fy_end_date: this.data.fy_end_date,
valuation_method: this.data.valuation_method || "FIFO",
setup_demo: 0,
};
}
finalize() {
this.$wrap.find(".jey-body").html(
`<p>${__("Setting up your system. This can take a minute...")}</p>`
);
this.$wrap.find(".jey-nav").empty();
this.$wrap.find(".jey-status").empty();
const values = this.build_setup_args();
frappe.call({
method: "frappe.desk.page.setup_wizard.setup_wizard.setup_complete",
args: { args: values },
freeze: true,
callback: (r) => {
const m = r.message || {};
if (m.status === "ok") {
this.$wrap.find(".jey-body").html(
`<p>${__("Setup complete! Redirecting...")}</p>`
);
setTimeout(() => {
window.location.href = "/app";
}, 1500);
} else if (m.status === "registered") {
this.show_error(
__("Setup is running in background mode, not supported by skeleton"),
JSON.stringify(m, null, 2)
);
} else if (m.fail !== undefined) {
this.show_error(m.fail, JSON.stringify(m, null, 2));
} else {
this.show_error(
__("Setup returned unexpected response"),
JSON.stringify(m, null, 2)
);
}
},
error: () => {
const resp = frappe.last_response || {};
this.show_error(
resp.setup_wizard_failure_message || __("Setup failed"),
resp.exc || resp.exception || __("(check server logs)")
);
},
});
}
show_error(summary, detail) {
this.$wrap.find(".jey-body").html(`
<h3 style="color:#c00;margin-bottom:8px">${frappe.utils.escape_html(summary)}</h3>
<pre style="max-height:360px;overflow:auto;background:#f7f7f7;padding:12px;border-radius:4px;font-size:12px;white-space:pre-wrap">${
detail ? frappe.utils.escape_html(String(detail)) : __("(no details)")
}</pre>
`);
this.$wrap.find(".jey-status").empty();
this.$wrap.find(".jey-nav")
.empty()
.append(
$(`<button class="btn btn-default jey-retry">${__("Retry")}</button>`)
.on("click", () => window.location.reload())
);
}
};