jey_wizard/jey_wizard/public/js/jey_setup.js

1446 lines
56 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.16";
// 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",
accounting_method: "Hesablama metodu",
// Auto-pick Kassa for micro entities. Reset to true only when
// fetch_company_profile returns a known criteria — without that
// signal we keep the static default. Toggled to true the moment
// the user touches the select so the criteria heuristic stops
// overwriting their choice.
accounting_method_user_edited: false,
business_criteria: "",
// Admin user (fresh user step — Administrator stays untouched)
user_full_name: "",
user_email: "",
user_password: "",
// 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", "user", "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 === "user") this.render_user($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>
<div class="jey-fy-start"></div>
</div>
<div style="flex:1">
<label style="display:block;margin-bottom:4px">${__("End date")}</label>
<div class="jey-fy-end"></div>
</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>
<h4 style="margin-top:20px;margin-bottom:10px">${__("Uçot metodu (Accounting Method)")}</h4>
<div style="max-width:280px">
<label style="display:block;margin-bottom:4px">${__("Accounting method")}</label>
<select class="jey-accounting-method form-control">
<option value="Hesablama metodu">${__("Hesablama metodu")}</option>
<option value="Kassa metodu">${__("Kassa metodu")}</option>
</select>
<div class="jey-accounting-hint" style="color:#888;font-size:12px;margin-top:6px">${__("Stored in Company → Tax Policy.")}</div>
</div>
`);
$body.find(".jey-company-name").val(this.data.company_name);
// Mount native Frappe Date controls (Flatpickr-styled) into the
// jey-fy-start / jey-fy-end placeholders. Plain <input type="date">
// looks dated and inconsistent with the rest of Frappe's UI. The
// controls expose set_value / get_value; we wire those into the
// usual data dict.
this.fy_start_ctrl = frappe.ui.form.make_control({
df: { fieldtype: "Date", fieldname: "fy_start_date", placeholder: "" },
parent: $body.find(".jey-fy-start")[0],
render_input: true,
});
this.fy_start_ctrl.set_value(this.data.fy_start_date || "");
this.fy_end_ctrl = frappe.ui.form.make_control({
df: { fieldtype: "Date", fieldname: "fy_end_date", placeholder: "" },
parent: $body.find(".jey-fy-end")[0],
render_input: true,
});
this.fy_end_ctrl.set_value(this.data.fy_end_date || "");
$body.find(".jey-valuation-method").val(this.data.valuation_method || "FIFO");
$body.find(".jey-accounting-method").val(this.data.accounting_method || "Hesablama metodu");
// Show why the default landed where it did, only when the auto-pick
// from criteriaOfBusinessEntity was actually applied.
if (this.data.business_criteria && !this.data.accounting_method_user_edited) {
const isMicro = this.data.business_criteria === "micro";
$body.find(".jey-accounting-hint").text(
isMicro
? __("Auto-selected because the e-taxes profile classifies this entity as Micro.")
: __("Auto-selected because the e-taxes profile classifies this entity as {0}.", [this.data.business_criteria])
);
}
// Once the user manually picks anything, stop reapplying the
// criteria-based default on subsequent renders / re-fetches.
$body.find(".jey-accounting-method").on("change", () => {
this.data.accounting_method_user_edited = true;
});
// 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;
const v = ($(e.currentTarget).val() || "").toUpperCase().slice(0, 10);
this.data.company_abbr = v;
$(e.currentTarget).val(v);
});
}
derive_abbr(rawName) {
// Azerbaijani registered names follow the pattern
// "<Core Name>" <LEGAL FORM>
// where the core is always quoted and the legal form is spelled out in
// full ("Məhdud Məsuliyyətli Cəmiyyəti"). The nice abbreviation follows
// that split: initials of the core + short code of the legal form
// ("TEST SOFT" MMC → "TS MMC"). If no quotes are present we fall back
// to a simpler deriver.
if (!rawName) return "";
const QUOTE_CHARS = "\"“”„‟«»‘’‚‛'`´″′‹›";
const stripEdgeQuotes = (s) =>
String(s || "").replace(new RegExp(`^[${QUOTE_CHARS}\\s]+|[${QUOTE_CHARS}\\s]+$`, "g"), "");
const legalFormCode = (text) => {
// Treat Ə/ə the same as E/e and match common full-word AZ legal forms.
const ascii = String(text || "").replace(/Ə/g, "E").replace(/ə/g, "e");
const rules = [
[/\bMEHDUD\s+MESULIYYETLI\s+CEMIYYETI\b/i, "MMC"],
[/\bA[CÇ]IQ\s+SEHMDAR\s+CEMIYYETI\b/i, "ASC"],
[/\bQAPALI\s+SEHMDAR\s+CEMIYYETI\b/i, "QSC"],
[/\bSEHMDAR\s+CEMIYYETI\b/i, "SC"],
[/\bKOOPERATIV\b/i, "K"],
[/\bFERDI\s+SAHIBKAR\b/i, "FS"],
];
for (const [re, code] of rules) if (re.test(ascii)) return code;
return "";
};
const initialsOf = (text, max = 4) => {
const parts = stripEdgeQuotes(text).split(/\s+/).filter(Boolean);
if (parts.length === 0) return "";
if (parts.length === 1) return parts[0].slice(0, 3).toUpperCase();
return parts.slice(0, max).map((p) => p[0]).join("").toUpperCase();
};
const raw = String(rawName);
// Find the first quoted segment using any recognised quote character.
const quoted = raw.match(new RegExp(`[${QUOTE_CHARS}]([^${QUOTE_CHARS}]+?)[${QUOTE_CHARS}]`));
if (quoted) {
const core = quoted[1];
const outside = (raw.slice(0, quoted.index) + " " + raw.slice(quoted.index + quoted[0].length))
.trim();
const coreAbbr = initialsOf(core, 4);
const legalCode = legalFormCode(outside);
const combined = [coreAbbr, legalCode].filter(Boolean).join(" ").slice(0, 10);
if (combined) return combined;
}
// Unquoted fallback: strip quotes, collapse known legal forms inline, take
// first letters.
let clean = raw.replace(new RegExp(`[${QUOTE_CHARS}]`, "g"), " ");
const legal = legalFormCode(clean);
if (legal) {
// Remove the legal form text from the core, then combine.
clean = clean
.replace(/Ə/g, "E").replace(/ə/g, "e")
.replace(/\bMEHDUD\s+MESULIYYETLI\s+CEMIYYETI\b/gi, " ")
.replace(/\bA[CÇ]IQ\s+SEHMDAR\s+CEMIYYETI\b/gi, " ")
.replace(/\bQAPALI\s+SEHMDAR\s+CEMIYYETI\b/gi, " ")
.replace(/\bSEHMDAR\s+CEMIYYETI\b/gi, " ")
.replace(/\bKOOPERATIV\b/gi, " ")
.replace(/\bFERDI\s+SAHIBKAR\b/gi, " ");
const core = initialsOf(clean, 4);
return [core, legal].filter(Boolean).join(" ").slice(0, 10);
}
return initialsOf(clean, 4).slice(0, 10);
}
render_user($body) {
// Collect a real admin user separate from Frappe's Administrator. We
// pass full_name/email/password into setup_complete; Frappe will create
// this user during run_setup_success. Administrator itself stays as
// the system-of-last-resort fallback login.
$body.html(`
<h3 style="margin-bottom:8px">${__("Administrator account")}</h3>
<p style="color:#888;font-size:13px;margin-bottom:16px">${__("Create the day-to-day admin account. The default Administrator login is preserved as a fallback.")}</p>
<div style="margin-bottom:12px;max-width:420px">
<label style="display:block;margin-bottom:4px">${__("Full name")}</label>
<input type="text" class="jey-user-name form-control" autocomplete="name" />
</div>
<div style="margin-bottom:12px;max-width:420px">
<label style="display:block;margin-bottom:4px">${__("Email (login)")}</label>
<input type="email" class="jey-user-email form-control" autocomplete="email" />
</div>
<div style="margin-bottom:12px;max-width:420px">
<label style="display:block;margin-bottom:4px">${__("Password")}</label>
<input type="password" class="jey-user-password form-control" autocomplete="new-password" />
<div style="color:#888;font-size:12px;margin-top:4px">${__("Minimum 8 characters.")}</div>
</div>
<div style="margin-bottom:12px;max-width:420px">
<label style="display:block;margin-bottom:4px">${__("Confirm password")}</label>
<input type="password" class="jey-user-password2 form-control" autocomplete="new-password" />
</div>
`);
$body.find(".jey-user-name").val(this.data.user_full_name);
$body.find(".jey-user-email").val(this.data.user_email);
$body.find(".jey-user-password").val(this.data.user_password);
$body.find(".jey-user-password2").val(this.data.user_password);
}
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>${__("Accounting method")}:</b> ${frappe.utils.escape_html(this.data.accounting_method || "Hesablama metodu")}</li>
<li><b>${__("Admin user")}:</b> ${frappe.utils.escape_html(this.data.user_full_name)} <span style="color:#888">(${frappe.utils.escape_html(this.data.user_email)})</span></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, " ")
.trim();
const abbr = abbrRaw || this.derive_abbr(name);
if (!abbr) {
this.$wrap.find(".jey-status").text(__("Company abbreviation is required"));
return false;
}
// Allow letters, digits and a single space — e.g. "TS MMC" for
// the Azerbaijani quoted-core + legal-form pattern.
if (!/^[A-Z0-9 ]{1,10}$/.test(abbr)) {
this.$wrap.find(".jey-status").text(__("Abbreviation must be 110 letters/digits (AZ, 09, spaces allowed)."));
return false;
}
this.data.company_abbr = abbr;
// Frappe Date controls return the value as YYYY-MM-DD or "" when
// blank. Mirrors the previous .val() contract on <input type="date">.
const fyStart = String((this.fy_start_ctrl && this.fy_start_ctrl.get_value()) || "").trim();
const fyEnd = String((this.fy_end_ctrl && this.fy_end_ctrl.get_value()) || "").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";
const am = (this.$wrap.find(".jey-accounting-method").val() || "").trim();
this.data.accounting_method = (am === "Kassa metodu") ? "Kassa metodu" : "Hesablama metodu";
} else if (step === "user") {
const fullName = (this.$wrap.find(".jey-user-name").val() || "").trim();
const email = (this.$wrap.find(".jey-user-email").val() || "").trim().toLowerCase();
const pw = this.$wrap.find(".jey-user-password").val() || "";
const pw2 = this.$wrap.find(".jey-user-password2").val() || "";
if (!fullName) {
this.$wrap.find(".jey-status").text(__("Full name is required"));
return false;
}
// Permissive check — Frappe revalidates on the server.
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
this.$wrap.find(".jey-status").text(__("Enter a valid email"));
return false;
}
if (email === "administrator" || email === "admin@example.com") {
this.$wrap.find(".jey-status").text(__("Pick a different email — that one is reserved"));
return false;
}
if (pw.length < 8) {
this.$wrap.find(".jey-status").text(__("Password must be at least 8 characters"));
return false;
}
if (pw !== pw2) {
this.$wrap.find(".jey-status").text(__("Passwords do not match"));
return false;
}
this.data.user_full_name = fullName;
this.data.user_email = email;
this.data.user_password = pw;
}
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() {
// Sequence: fetch_all_etaxes → fetch_company_profile. Both run with the
// same fresh main_token; the profile pull also does dictionary lookups
// (taxAuthority/propertyType) that need the token, so we want them done
// while the user is still on the "fetching" screen — by finalize-time
// the token may have expired.
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.fetch_company_profile();
},
error: () => {
this.asan_fetch_summary = {};
this.asan_fetch_errors = { all: __("fetch call failed — see server logs") };
// Still try the profile pull — independent payload, useful even
// if the lists call hiccupped.
this.fetch_company_profile();
},
});
}
fetch_company_profile() {
frappe.call({
method: "jey_wizard.etaxes.fetch_company_profile",
callback: (r) => {
const m = r.message || {};
this.data.business_criteria = (m.criteria || "").toLowerCase();
// Heuristic: micro businesses use cash accounting, everyone
// else accrual. Skip if the user has already touched the
// select on the company step.
if (!this.data.accounting_method_user_edited && this.data.business_criteria) {
this.data.accounting_method =
this.data.business_criteria === "micro"
? "Kassa metodu"
: "Hesablama metodu";
}
this.asan_state = "done";
this.show_current_step();
},
error: () => {
// Profile fetch is best-effort. Materialize will skip cleanly
// if the cache stays empty; nothing to surface to the user here.
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. Collapse
// runs of whitespace but keep a single space (e.g. "TS MMC").
const abbr = (
(this.data.company_abbr || "").toUpperCase().replace(/\s+/g, " ").trim() ||
this.derive_abbr(company) ||
"CO"
).slice(0, 10);
return {
language: LANG_NAME[this.data.language] || "English",
country: "Azerbaijan",
timezone: "Asia/Baku",
currency: "AZN",
enable_telemetry: 0,
// Admin user fields come from the dedicated `user` step. We never
// reuse Administrator's identity here — that's why the previous
// "username `administrator` already exists" warning showed up.
full_name: this.data.user_full_name,
email: this.data.user_email,
password: this.data.user_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",
accounting_method: this.data.accounting_method || "Hesablama metodu",
setup_demo: 0,
};
}
finalize() {
// Render an inline progress UI in place of the wizard form. We don't
// pass freeze:true to frappe.call — the freeze overlay would dim and
// hide the bar. process_setup_stages publishes realtime "setup_task"
// events with {progress: [idx, total], stage_status: "..."}; we
// subscribe and translate those into bar width + status text.
this.$wrap.find(".jey-body").html(`
<h3 style="margin-bottom:12px">${__("Setting up your system")}</h3>
<div class="jey-finalize-status" style="color:#666;margin-bottom:12px;min-height:1.4em">${__("Starting...")}</div>
<div style="background:#eee;border-radius:6px;height:12px;overflow:hidden">
<div class="jey-finalize-bar" style="background:#3b82f6;height:100%;width:5%;transition:width 0.4s ease"></div>
</div>
<div class="jey-finalize-pct" style="color:#888;font-size:12px;margin-top:6px;text-align:right">5%</div>
`);
this.$wrap.find(".jey-nav").empty();
this.$wrap.find(".jey-status").empty();
const setProgress = (pct, status) => {
pct = Math.max(0, Math.min(100, Math.round(pct)));
this.$wrap.find(".jey-finalize-bar").css("width", pct + "%");
this.$wrap.find(".jey-finalize-pct").text(pct + "%");
if (status) this.$wrap.find(".jey-finalize-status").text(status);
};
// Realtime subscription. Each "setup_task" event carries either a
// progress tuple or a final status. Cap displayed pct at 95% during
// the run so the bar visibly completes when the HTTP response lands.
const onSetupTask = (data) => {
if (!data) return;
if (Array.isArray(data.progress)) {
const [done, total] = data.progress;
if (total > 0) {
const ratio = Math.min(done / total, 0.95);
setProgress(5 + ratio * 90, data.stage_status || __("Working..."));
}
}
if (data.status === "fail") {
this.show_error(
data.fail_msg || __("Setup failed"),
data.exc || data.exception || __("(check server logs)")
);
}
};
frappe.realtime.on("setup_task", onSetupTask);
const cleanup = () => frappe.realtime.off("setup_task", onSetupTask);
const values = this.build_setup_args();
frappe.call({
method: "frappe.desk.page.setup_wizard.setup_wizard.setup_complete",
args: { args: values },
callback: (r) => {
const m = r.message || {};
if (m.status === "ok") {
setProgress(100, __("Setup complete! Redirecting..."));
cleanup();
setTimeout(() => {
window.location.href = "/app";
}, 800);
} else if (m.status === "registered") {
cleanup();
this.show_error(
__("Setup is running in background mode, not supported by skeleton"),
JSON.stringify(m, null, 2)
);
} else if (m.fail !== undefined) {
cleanup();
this.show_error(m.fail, JSON.stringify(m, null, 2));
} else {
cleanup();
this.show_error(
__("Setup returned unexpected response"),
JSON.stringify(m, null, 2)
);
}
},
error: () => {
cleanup();
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())
);
}
};