diff --git a/av_tools/av_tools/page/salary_calculator/__init__.py b/av_tools/av_tools/page/salary_calculator/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/av_tools/av_tools/page/salary_calculator/salary_calculator.css b/av_tools/av_tools/page/salary_calculator/salary_calculator.css
new file mode 100644
index 0000000..23e2b81
--- /dev/null
+++ b/av_tools/av_tools/page/salary_calculator/salary_calculator.css
@@ -0,0 +1,126 @@
+/* ── Page Layout ───────────────────────────────────────────────────── */
+
+.salary-calculator-page { padding: 0 !important; background: var(--bg-color); }
+
+.sc-container {
+ display: grid;
+ grid-template-columns: 440px 1fr;
+ gap: 20px;
+ padding: 20px;
+ max-width: 1400px;
+ margin: 0 auto;
+ min-height: calc(100vh - 120px);
+}
+@media (max-width: 1024px) { .sc-container { grid-template-columns: 1fr; } }
+
+/* ── Cards ─────────────────────────────────────────────────────────── */
+
+.sc-card {
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius-lg);
+ margin-bottom: 16px;
+ overflow: hidden;
+}
+.sc-card-header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--border-color);
+ background: var(--subtle-fg);
+}
+.sc-card-title { font-size: var(--text-base); font-weight: 600; margin: 0; color: var(--heading-color); }
+.sc-card-body { padding: 18px; }
+.sc-preview-card { position: sticky; top: 80px; }
+
+/* ── Fields ────────────────────────────────────────────────────────── */
+
+.sc-field-wrap { margin-bottom: 12px; }
+.sc-field-wrap:last-child { margin-bottom: 0; }
+.sc-field-wrap .frappe-control { margin-bottom: 0; }
+.sc-amount-row { display: flex; gap: 12px; }
+.sc-field-half { flex: 1; }
+.sc-field-disabled .control-input-wrapper input {
+ background: var(--subtle-fg) !important; color: var(--text-muted) !important; cursor: not-allowed;
+}
+
+/* ── Component List ────────────────────────────────────────────────── */
+
+.sc-header-right { display: flex; align-items: center; gap: 8px; }
+.sc-badge {
+ display: inline-flex; align-items: center; padding: 2px 10px;
+ border-radius: 20px; font-size: var(--text-xs); font-weight: 500;
+ background: var(--bg-blue); color: var(--text-color);
+}
+.sc-section { margin-bottom: 14px; }
+.sc-section:last-child { margin-bottom: 0; }
+.sc-section-label {
+ display: flex; align-items: center; gap: 6px;
+ font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
+ text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px;
+}
+.sc-dot { width: 8px; height: 8px; border-radius: 50%; }
+.sc-dot-green { background: var(--green-500, #22c55e); }
+.sc-dot-red { background: var(--red-500, #ef4444); }
+
+.sc-list { display: flex; flex-direction: column; gap: 4px; }
+
+.sc-comp-row {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 7px 10px; border: 1px solid var(--border-color);
+ border-radius: var(--border-radius-md); background: var(--card-bg);
+ transition: background .12s; gap: 8px;
+}
+.sc-comp-row:hover { background: var(--subtle-fg); }
+.sc-comp-info { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
+.sc-comp-name { font-size: var(--text-sm); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.sc-comp-hint { font-size: var(--text-xs); color: var(--text-light); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; }
+.sc-tag {
+ font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px;
+ background: var(--bg-purple); color: var(--purple-600, #9333ea);
+ text-transform: uppercase; letter-spacing: .3px; flex-shrink: 0;
+}
+.sc-remove-btn {
+ flex-shrink: 0; width: 24px; height: 24px; padding: 0; line-height: 24px; text-align: center;
+ border-radius: var(--border-radius-sm); color: var(--text-muted);
+ border: none; background: transparent; font-size: 16px; transition: all .12s;
+}
+.sc-remove-btn:hover { background: var(--bg-red); color: var(--red-600, #dc2626); }
+.sc-empty-msg { font-size: var(--text-xs); padding: 6px 10px; font-style: italic; }
+
+/* ── Editable Earning Amount ───────────────────────────────────────── */
+
+.sc-comp-amount {
+ flex-shrink: 0; width: 110px;
+}
+.sc-comp-amount .frappe-control { margin-bottom: 0; }
+.sc-comp-amount .control-input-wrapper input {
+ font-size: var(--text-xs); height: 28px; padding: 2px 8px; text-align: right;
+}
+
+/* ── Preview ───────────────────────────────────────────────────────── */
+
+.sc-placeholder { display: flex; flex-direction: column; align-items: center; padding: 60px 20px; text-align: center; }
+.sc-placeholder p { color: var(--text-muted); font-size: var(--text-sm); max-width: 220px; margin-top: 12px; line-height: 1.5; }
+
+/* Salary Slip Preview */
+.salary-slip-preview-content { font-size: var(--text-sm); }
+.slip-header { margin-bottom: 18px; padding-bottom: 14px; border-bottom: 2px solid var(--border-color); }
+.slip-title { font-size: var(--text-lg); font-weight: 700; margin-bottom: 10px; }
+.slip-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px; }
+.meta-row { display: flex; gap: 6px; }
+.meta-label { font-weight: 600; color: var(--text-muted); white-space: nowrap; }
+.slip-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 18px; }
+@media (max-width: 768px) { .slip-columns { grid-template-columns: 1fr; } }
+.column-title { font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px; padding-bottom: 4px; border-bottom: 2px solid var(--border-color); }
+.earnings-title { color: var(--green-600, #16a34a); border-color: var(--green-200, #bbf7d0); }
+.deductions-title { color: var(--red-600, #dc2626); border-color: var(--red-200, #fecaca); }
+.slip-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
+.slip-table th { font-size: var(--text-xs); font-weight: 600; color: var(--text-muted); padding: 5px 0; border-bottom: 1px solid var(--border-color); }
+.slip-table td { padding: 5px 0; border-bottom: 1px solid var(--subtle-fg); }
+.slip-table .total-row td { border-top: 2px solid var(--border-color); border-bottom: none; padding-top: 6px; }
+.slip-footer { background: var(--subtle-fg); border-radius: var(--border-radius-md); padding: 14px; }
+.net-pay-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
+.net-pay-label { font-size: var(--text-lg); font-weight: 700; }
+.net-pay-value { font-size: var(--text-xl); font-weight: 700; color: var(--blue-600, #2563eb); }
+.gross-pay-row { font-size: var(--text-xs); color: var(--text-muted); display: flex; justify-content: center; gap: 8px; }
+.gross-pay-row .separator { color: var(--gray-400); }
diff --git a/av_tools/av_tools/page/salary_calculator/salary_calculator.js b/av_tools/av_tools/page/salary_calculator/salary_calculator.js
new file mode 100644
index 0000000..2054787
--- /dev/null
+++ b/av_tools/av_tools/page/salary_calculator/salary_calculator.js
@@ -0,0 +1,490 @@
+frappe.pages["salary-calculator"].on_page_load = function (wrapper) {
+ const page = frappe.ui.make_app_page({
+ parent: wrapper,
+ title: "Salary Calculator",
+ single_column: true,
+ });
+ frappe.breadcrumbs.add("HR");
+ new SalaryCalculatorPage(page);
+};
+
+class SalaryCalculatorPage {
+ constructor(page) {
+ this.page = page;
+ this.state = {
+ salary_structure: "",
+ employee: "",
+ calculate_based_on: "Net Pay",
+ gross_pay: 0,
+ net_pay: 0,
+ };
+ this.structure_data = null;
+ this.active_keys = new Set();
+ this.earning_overrides = {};
+ this.result = null;
+ this.currency = "TZS";
+ this._calc_timer = null;
+
+ this.render_layout();
+ this.create_fields();
+ }
+
+ // ── Layout ──────────────────────────────────────────────────────────
+
+ render_layout() {
+ this.page.main.addClass("salary-calculator-page").html(`
+
+
+
+
+
+
+
+
+
Select a salary structure and enter an amount to begin
+
+
+
+
+
+
+ `);
+ }
+
+ // ── Fields ───────────────────────────────────────────────────────────
+
+ create_fields() {
+ const $fields = this.page.main.find(".sc-fields");
+ this.fields = {};
+
+ const defs = [
+ {
+ fieldtype: "Link", fieldname: "salary_structure", label: "Salary Structure",
+ options: "Salary Structure", reqd: 1,
+ get_query: () => ({ filters: { is_active: "Yes", docstatus: 1 } }),
+ change: () => this.on_structure_change(),
+ },
+ {
+ fieldtype: "Link", fieldname: "employee", label: "Employee",
+ options: "Employee",
+ get_query: () => ({ filters: { status: "Active" } }),
+ change: () => { this.state.employee = this.val("employee"); this.schedule_calc(); },
+ },
+ {
+ fieldtype: "Select", fieldname: "calculate_based_on", label: "Calculate Based On",
+ options: ["Net Pay", "Gross Pay"], default: "Net Pay", reqd: 1,
+ change: () => { this.state.calculate_based_on = this.val("calculate_based_on"); this.apply_field_states(); this.schedule_calc(); },
+ },
+ ];
+
+ for (const df of defs) {
+ const $w = $('').appendTo($fields);
+ this.fields[df.fieldname] = frappe.ui.form.make_control({ df, parent: $w, render_input: true });
+ }
+ this.fields.calculate_based_on.set_value("Net Pay");
+
+ const $row = $('').appendTo($fields);
+ for (const name of ["gross_pay", "net_pay"]) {
+ const $w = $('').appendTo($row);
+ this.fields[name] = frappe.ui.form.make_control({
+ df: {
+ fieldtype: "Currency", fieldname: name, precision: 0,
+ label: name === "gross_pay" ? "Gross Pay" : "Net Pay",
+ change: () => {
+ this.state.gross_pay = flt(this.val("gross_pay"));
+ this.state.net_pay = flt(this.val("net_pay"));
+ this.schedule_calc();
+ },
+ },
+ parent: $w, render_input: true,
+ });
+ }
+
+ this.apply_field_states();
+ }
+
+ val(name) { return this.fields[name]?.get_value(); }
+
+ apply_field_states() {
+ const is_net = this.state.calculate_based_on === "Net Pay";
+ for (const [name, disabled] of [["gross_pay", is_net], ["net_pay", !is_net]]) {
+ const f = this.fields[name];
+ if (f?.$input) {
+ f.$input.prop("disabled", disabled);
+ f.$wrapper.toggleClass("sc-field-disabled", disabled);
+ }
+ }
+ }
+
+ // ── Structure Change ────────────────────────────────────────────────
+
+ on_structure_change() {
+ const value = this.val("salary_structure");
+ this.state.salary_structure = value;
+
+ if (!value) {
+ this.structure_data = null;
+ this.active_keys = new Set();
+ this.earning_overrides = {};
+ this.result = null;
+ this.$(".sc-components-card").hide();
+ this.show_placeholder();
+ return;
+ }
+
+ frappe.xcall(
+ "av_tools.av_tools.page.salary_calculator.salary_calculator.get_salary_structure_components",
+ { salary_structure: value },
+ ).then((data) => {
+ this.structure_data = data;
+ this.currency = data.currency || "TZS";
+ this.earning_overrides = {};
+
+ this.active_keys = new Set();
+ for (const comp of [...data.earnings, ...data.deductions]) {
+ if (!comp.statistical_component && !comp.do_not_include_in_total) {
+ this.active_keys.add(comp.key);
+ }
+ }
+
+ this.render_components();
+ this.$(".sc-components-card").show();
+ this.schedule_calc();
+ });
+ }
+
+ // ── Components UI ───────────────────────────────────────────────────
+
+ render_components() {
+ if (!this.structure_data) return;
+ this.render_component_list(this.$(".sc-earnings-list"), this.structure_data.earnings, true);
+ this.render_component_list(this.$(".sc-deductions-list"), this.structure_data.deductions, false);
+ this.update_counts();
+ }
+
+ render_component_list($list, components, is_earning) {
+ $list.empty();
+ let has_rows = false;
+ const seen = new Set();
+
+ for (const comp of components) {
+ if (comp.statistical_component || comp.do_not_include_in_total) continue;
+ if (!this.active_keys.has(comp.key)) continue;
+
+ // Deduplicate deductions by salary_component name (e.g. 5 PAYE tiers → 1 row)
+ if (!is_earning) {
+ if (seen.has(comp.salary_component)) continue;
+ seen.add(comp.salary_component);
+ }
+
+ has_rows = true;
+ $list.append(this.build_component_row(comp, is_earning));
+ }
+
+ if (!has_rows) {
+ $list.append('None
');
+ }
+ }
+
+ build_component_row(comp, is_earning) {
+ const is_base = this.is_base(comp);
+ const hint = comp.formula
+ ? `Formula: ${comp.formula}`
+ : comp.amount ? `Amount: ${comp.amount}` : "";
+ const esc = frappe.utils.escape_html;
+
+ const $row = $(`
+
+
+ ${esc(comp.salary_component)}
+ ${is_base ? 'Base' : ""}
+ ${hint ? `${esc(hint)}` : ""}
+
+ ${is_earning && !is_base ? '
' : ""}
+ ${!is_base ? '
' : ""}
+
+ `);
+
+ // Editable amount input for non-base earnings
+ if (is_earning && !is_base) {
+ const $wrap = $row.find(".sc-comp-amount");
+ const comp_name = comp.salary_component;
+ const ctrl = frappe.ui.form.make_control({
+ df: {
+ fieldtype: "Currency", fieldname: `ovr_${comp.key}`,
+ placeholder: "Amount", precision: 0,
+ change: () => {
+ const v = flt(ctrl.get_value());
+ if (v > 0) {
+ this.earning_overrides[comp_name] = v;
+ } else {
+ delete this.earning_overrides[comp_name];
+ }
+ this.schedule_calc();
+ },
+ },
+ parent: $wrap, render_input: true,
+ });
+ const existing = this.earning_overrides[comp_name];
+ if (existing !== undefined) ctrl.set_value(existing);
+ }
+
+ // Remove button
+ if (!is_base) {
+ $row.find(".sc-remove-btn").on("click", () => {
+ if (is_earning) {
+ this.active_keys.delete(comp.key);
+ delete this.earning_overrides[comp.salary_component];
+ } else {
+ // Remove ALL keys for this salary_component (handles dupes like PAYE)
+ for (const c of this.structure_data.deductions) {
+ if (c.salary_component === comp.salary_component) {
+ this.active_keys.delete(c.key);
+ }
+ }
+ }
+ this.render_components();
+ this.schedule_calc();
+ });
+ }
+
+ return $row;
+ }
+
+ is_base(comp) {
+ const f = (comp.formula || "").replace(/\s/g, "").toLowerCase();
+ return comp.abbr === "B" || (comp.salary_component || "").toLowerCase() === "basic" || f === "base";
+ }
+
+ get_inactive_components() {
+ if (!this.structure_data) return [];
+ const seen = new Set();
+ return [...this.structure_data.earnings, ...this.structure_data.deductions].filter((c) => {
+ if (c.statistical_component || c.do_not_include_in_total || this.is_base(c)) return false;
+ if (this.active_keys.has(c.key)) return false;
+ if (seen.has(c.salary_component)) return false;
+ seen.add(c.salary_component);
+ return true;
+ });
+ }
+
+ update_counts() {
+ this.$(".sc-count-badge").text(`${this.active_keys.size} active`);
+ const inactive = this.get_inactive_components();
+ const $btn = this.$(".sc-add-btn");
+ if (inactive.length) {
+ $btn.show().off("click").on("click", () => this.show_add_dialog());
+ } else {
+ $btn.hide();
+ }
+ }
+
+ show_add_dialog() {
+ const inactive = this.get_inactive_components();
+ if (!inactive.length) return;
+
+ const d = new frappe.ui.Dialog({
+ title: __("Add Salary Component"),
+ fields: [{
+ fieldtype: "Select", fieldname: "key", label: "Component", reqd: 1,
+ options: inactive.map((c) => ({ label: c.salary_component, value: c.key })),
+ }],
+ primary_action_label: __("Add"),
+ primary_action: ({ key }) => {
+ d.hide();
+ const selected = inactive.find((c) => c.key === key);
+ if (selected) {
+ // Re-activate ALL rows with same salary_component (handles deduction dupes)
+ for (const c of [...this.structure_data.earnings, ...this.structure_data.deductions]) {
+ if (c.salary_component === selected.salary_component) {
+ this.active_keys.add(c.key);
+ }
+ }
+ }
+ this.render_components();
+ this.schedule_calc();
+ },
+ });
+ d.show();
+ }
+
+ // ── Calculation ─────────────────────────────────────────────────────
+
+ get_selected_names() {
+ if (!this.structure_data) return [];
+ const names = [];
+ for (const comp of [...this.structure_data.earnings, ...this.structure_data.deductions]) {
+ if (this.active_keys.has(comp.key) && !comp.statistical_component && !comp.do_not_include_in_total) {
+ names.push(comp.salary_component);
+ }
+ }
+ return names;
+ }
+
+ schedule_calc() {
+ if (this._applying_result) return;
+ clearTimeout(this._calc_timer);
+ this._calc_timer = setTimeout(() => this.run_calculation(), 300);
+ }
+
+ run_calculation() {
+ const ss = this.state.salary_structure;
+ const mode = this.state.calculate_based_on;
+ if (!ss || !mode) return;
+
+ const target = mode === "Net Pay" ? flt(this.val("net_pay")) : flt(this.val("gross_pay"));
+ if (!target) {
+ this.result = null;
+ this.show_placeholder();
+ return;
+ }
+
+ frappe.xcall(
+ "av_tools.av_tools.page.salary_calculator.salary_calculator.run_calculation",
+ {
+ salary_structure: ss,
+ calculate_based_on: mode,
+ gross_pay: this.state.gross_pay,
+ net_pay: this.state.net_pay,
+ selected_components: this.get_selected_names(),
+ earning_overrides: this.earning_overrides,
+ employee: this.state.employee || undefined,
+ },
+ ).then((r) => {
+ if (!r) return;
+ this.result = r;
+
+ // Update computed field without retriggering calculation
+ this._applying_result = true;
+ if (mode === "Net Pay") {
+ this.fields.gross_pay.set_value(r.gross_pay);
+ } else {
+ this.fields.net_pay.set_value(r.net_pay);
+ }
+ this._applying_result = false;
+
+ this.refresh_preview();
+ }).catch((err) => {
+ this.result = null;
+ frappe.show_alert({ message: __("Calculation error: {0}", [err?.message || err]), indicator: "orange" }, 5);
+ });
+ }
+
+ // ── Preview ──────────────────────────────────────────────────────────
+
+ refresh_preview() {
+ const ss = this.state.salary_structure;
+ const r = this.result;
+
+ if (!ss || !r) {
+ this.show_placeholder();
+ return;
+ }
+
+ // HRMS path returns earnings/deductions directly; use them as-is
+ const earnings = r.earnings || [];
+ const deductions = r.deductions || [];
+
+ const emp = this.state.employee;
+ frappe.xcall(
+ "av_tools.av_tools.page.salary_calculator.salary_calculator.get_salary_slip_preview",
+ {
+ salary_structure: ss,
+ base: r.base,
+ gross_pay: r.gross_pay,
+ net_pay: r.net_pay,
+ earnings_data: earnings,
+ deductions_data: deductions,
+ employee: emp || undefined,
+ },
+ ).then((html) => {
+ this.$(".sc-placeholder").hide();
+ this.$(".sc-preview-content").html(html).show();
+ this.render_preview_actions();
+ });
+ }
+
+ show_placeholder() {
+ this.$(".sc-placeholder").show();
+ this.$(".sc-preview-content").hide();
+ this.$(".sc-preview-actions").empty();
+ }
+
+ // ── Assignment ───────────────────────────────────────────────────────
+
+ render_preview_actions() {
+ const $a = this.$(".sc-preview-actions").empty();
+ if (!this.state.employee || !this.result) return;
+
+ $('')
+ .appendTo($a)
+ .on("click", () => this.show_assign_dialog());
+ }
+
+ show_assign_dialog() {
+ const d = new frappe.ui.Dialog({
+ title: __("Create Salary Structure Assignment"),
+ fields: [
+ { fieldtype: "Link", fieldname: "employee", label: "Employee", options: "Employee", default: this.state.employee, read_only: 1 },
+ { fieldtype: "Link", fieldname: "salary_structure", label: "Salary Structure", options: "Salary Structure", default: this.state.salary_structure, read_only: 1 },
+ { fieldtype: "Currency", fieldname: "base", label: "Base Amount", default: this.result?.base || 0, precision: 0, description: "Calculated base salary" },
+ { fieldtype: "Date", fieldname: "from_date", label: "From Date", reqd: 1, default: frappe.datetime.get_today() },
+ ],
+ primary_action_label: __("Create & Submit"),
+ primary_action: (v) => {
+ d.hide();
+ frappe.xcall(
+ "av_tools.av_tools.page.salary_calculator.salary_calculator.create_salary_structure_assignment",
+ { employee: v.employee, salary_structure: v.salary_structure, from_date: v.from_date, base: v.base },
+ ).then((name) => {
+ frappe.show_alert({
+ message: __("Assignment {0} created", [`${name}`]),
+ indicator: "green",
+ }, 7);
+ });
+ },
+ });
+ d.show();
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────
+
+ $(selector) { return this.page.main.find(selector); }
+
+ fmt(amount) {
+ return format_number(flt(amount), null, this.currency === "TZS" ? 0 : 2);
+ }
+}
diff --git a/av_tools/av_tools/page/salary_calculator/salary_calculator.json b/av_tools/av_tools/page/salary_calculator/salary_calculator.json
new file mode 100644
index 0000000..d73208b
--- /dev/null
+++ b/av_tools/av_tools/page/salary_calculator/salary_calculator.json
@@ -0,0 +1,29 @@
+{
+ "content": null,
+ "creation": "2026-04-07 10:00:00.000000",
+ "docstatus": 0,
+ "doctype": "Page",
+ "idx": 0,
+ "modified": "2026-04-09 10:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Av Tools",
+ "name": "salary-calculator",
+ "owner": "Administrator",
+ "page_name": "Salary Calculator",
+ "roles": [
+ {
+ "role": "HR Manager"
+ },
+ {
+ "role": "HR User"
+ },
+ {
+ "role": "System Manager"
+ }
+ ],
+ "script": null,
+ "standard": "Yes",
+ "style": null,
+ "system_page": 0,
+ "title": "Salary Calculator"
+}
\ No newline at end of file
diff --git a/av_tools/av_tools/page/salary_calculator/salary_calculator.py b/av_tools/av_tools/page/salary_calculator/salary_calculator.py
new file mode 100644
index 0000000..ca397c0
--- /dev/null
+++ b/av_tools/av_tools/page/salary_calculator/salary_calculator.py
@@ -0,0 +1,451 @@
+"""Salary Calculator page backend.
+
+Uses HRMS ``make_salary_slip`` (for_preview=1) when an employee is provided,
+giving the full formula context (employee fields, payment days, tax handling).
+Falls back to a lightweight resilient engine when no employee is selected.
+"""
+from __future__ import annotations
+
+import json
+import re
+
+import frappe
+from frappe import _
+from frappe.utils import cint, cstr, flt
+
+
+# ── Public API ───────────────────────────────────────────────────────
+
+@frappe.whitelist()
+def get_salary_structure_components(salary_structure):
+ """Return earnings/deductions for a salary structure with unique keys."""
+ structure = frappe.get_cached_doc("Salary Structure", salary_structure)
+
+ def serialize(rows, prefix):
+ return [
+ {
+ "key": f"{prefix}-{i}",
+ "salary_component": row.salary_component,
+ "abbr": row.abbr,
+ "amount": flt(row.amount),
+ "amount_based_on_formula": row.amount_based_on_formula,
+ "formula": row.formula or "",
+ "condition": row.condition or "",
+ "do_not_include_in_total": row.do_not_include_in_total,
+ "statistical_component": row.statistical_component,
+ }
+ for i, row in enumerate(rows)
+ ]
+
+ return {
+ "earnings": serialize(structure.earnings, "E"),
+ "deductions": serialize(structure.deductions, "D"),
+ "currency": structure.currency or "",
+ }
+
+
+@frappe.whitelist()
+def run_calculation(
+ salary_structure, calculate_based_on, gross_pay=0, net_pay=0,
+ selected_components=None, earning_overrides=None, employee=None,
+):
+ """Calculate salary. Uses HRMS when employee provided, else lightweight fallback."""
+ if isinstance(selected_components, str):
+ selected_components = json.loads(selected_components)
+ if isinstance(earning_overrides, str):
+ earning_overrides = json.loads(earning_overrides)
+
+ overrides = earning_overrides or {}
+ target_field = "gross_pay" if calculate_based_on == "Gross Pay" else "net_pay"
+ target_amount = flt(gross_pay if target_field == "gross_pay" else net_pay)
+
+ if target_amount <= 0:
+ return _empty_result()
+
+ if employee:
+ try:
+ return _solve_via_hrms(salary_structure, employee, target_field, target_amount, overrides)
+ except Exception:
+ pass # Fall through to fallback
+
+ selected = list(dict.fromkeys(selected_components or []))
+ structure = frappe.get_cached_doc("Salary Structure", salary_structure)
+ precision = 0 if cstr(structure.currency) == "TZS" else 2
+ return _fallback_solve(structure, target_field, flt(target_amount, precision), precision, selected, overrides)
+
+
+@frappe.whitelist()
+def get_salary_slip_preview(
+ salary_structure, base, gross_pay, net_pay,
+ earnings_data, deductions_data, employee=None,
+):
+ """Render a salary slip preview from calculated data."""
+ if isinstance(earnings_data, str):
+ earnings_data = json.loads(earnings_data)
+ if isinstance(deductions_data, str):
+ deductions_data = json.loads(deductions_data)
+
+ emp = frappe.get_cached_doc("Employee", employee) if employee else None
+ currency = frappe.get_cached_value("Salary Structure", salary_structure, "currency") or "TZS"
+ fmt = (lambda a: format(flt(a), ",.0f")) if currency == "TZS" else (lambda a: format(flt(a), ",.2f"))
+
+ # CTC = Gross Pay + employer-cost contributions (deduction components flagged
+ # do_not_include_in_total, e.g. employer NSSF/pension/SDL/WCF).
+ ctc = flt(gross_pay)
+ comp_names = [d.get("salary_component") for d in deductions_data if d.get("salary_component")]
+ if comp_names:
+ employer_comps = set(
+ frappe.get_all(
+ "Salary Component",
+ filters={"name": ["in", comp_names], "do_not_include_in_total": 1},
+ pluck="name",
+ )
+ )
+ for d in deductions_data:
+ if d.get("salary_component") in employer_comps:
+ ctc += flt(d.get("amount"))
+
+ return frappe.render_template(
+ "av_tools/av_tools/page/salary_calculator/salary_slip_preview.html",
+ {
+ "employee": employee or "",
+ "employee_name": emp.employee_name if emp else "",
+ "department": (emp.department or "") if emp else "",
+ "designation": (emp.designation or "") if emp else "",
+ "company": emp.company if emp else "",
+ "salary_structure": salary_structure,
+ "currency": currency,
+ "base": flt(base),
+ "gross_pay": flt(gross_pay),
+ "net_pay": flt(net_pay),
+ "ctc": flt(ctc),
+ "total_earning": sum(flt(e.get("amount")) for e in earnings_data),
+ "total_deduction": sum(flt(d.get("amount")) for d in deductions_data),
+ "earnings": earnings_data,
+ "deductions": deductions_data,
+ "format_amount": fmt,
+ },
+ )
+
+
+@frappe.whitelist()
+def create_salary_structure_assignment(employee, salary_structure, from_date, base=0):
+ """Create and submit a Salary Structure Assignment."""
+ if frappe.db.exists(
+ "Salary Structure Assignment",
+ {"employee": employee, "salary_structure": salary_structure, "from_date": from_date, "docstatus": ["!=", 2]},
+ ):
+ frappe.throw(
+ _("Salary Structure Assignment already exists for {0} with {1} from {2}").format(
+ frappe.bold(employee), frappe.bold(salary_structure), frappe.bold(from_date),
+ )
+ )
+
+ ssa = frappe.new_doc("Salary Structure Assignment")
+ ssa.employee = employee
+ ssa.salary_structure = salary_structure
+ ssa.from_date = from_date
+ ssa.base = flt(base)
+ ssa.company = frappe.get_cached_value("Employee", employee, "company")
+ ssa.save()
+ ssa.submit()
+ return ssa.name
+
+
+# ── HRMS-based Calculation ───────────────────────────────────────────
+
+def _empty_result():
+ return {"base": 0, "gross_pay": 0, "net_pay": 0, "total_deductions": 0, "earnings": [], "deductions": []}
+
+
+def _make_preview_slip(salary_structure, employee, base_override=None, earning_overrides=None):
+ """Create an in-memory salary slip using HRMS, optionally overriding base/earnings."""
+ from hrms.payroll.doctype.salary_structure.salary_structure import make_salary_slip
+
+ ss = make_salary_slip(salary_structure, employee=employee, for_preview=1)
+
+ if base_override is not None:
+ ss._salary_structure_assignment["base"] = flt(base_override)
+ ss.set("earnings", [])
+ ss.set("deductions", [])
+ ss.process_salary_structure(for_preview=1)
+
+ if earning_overrides:
+ changed = False
+ for row in ss.earnings:
+ if row.salary_component in earning_overrides:
+ row.amount = flt(earning_overrides[row.salary_component])
+ row.default_amount = row.amount
+ changed = True
+ if changed:
+ ss.calculate_net_pay()
+
+ return ss
+
+
+def _format_slip_result(ss):
+ """Extract a result dict from a salary slip object."""
+ currency = cstr(getattr(ss, "currency", ""))
+ precision = 0 if currency == "TZS" else 2
+ base = flt(ss._salary_structure_assignment.get("base", 0), precision) if hasattr(ss, "_salary_structure_assignment") else 0
+ return {
+ "base": base,
+ "gross_pay": flt(ss.gross_pay, precision),
+ "net_pay": flt(ss.net_pay, precision),
+ "total_deductions": flt(ss.total_deduction, precision),
+ "earnings": [
+ {"salary_component": r.salary_component, "amount": flt(r.amount)}
+ for r in ss.earnings
+ ],
+ "deductions": [
+ {"salary_component": r.salary_component, "amount": flt(r.amount)}
+ for r in ss.deductions
+ ],
+ }
+
+
+def _solve_via_hrms(salary_structure, employee, target_field, target_amount, earning_overrides=None):
+ """Binary-search for the base that produces the target gross/net using HRMS."""
+ lo, hi = 0.0, max(target_amount * 3, 1)
+ best, best_val = None, None
+
+ # Suppress repeated msgprint calls during binary search iterations
+ original_messages = frappe.local.message_log[:]
+ frappe.flags.mute_messages = True
+ try:
+ for i in range(60):
+ mid = (lo + hi) / 2
+ ss = _make_preview_slip(salary_structure, employee, base_override=mid, earning_overrides=earning_overrides)
+ val = flt(ss.gross_pay) if target_field == "gross_pay" else flt(ss.net_pay)
+
+ if best is None or abs(val - target_amount) < abs(best_val - target_amount):
+ best, best_val = ss, val
+
+ if abs(val - target_amount) <= 1:
+ break
+ if i > 2 and val == 0:
+ return _empty_result()
+ if val < target_amount:
+ lo = mid
+ else:
+ hi = mid
+ finally:
+ frappe.flags.mute_messages = False
+ frappe.local.message_log = original_messages
+
+ return _format_slip_result(best) if best else _empty_result()
+
+
+# ── Fallback Engine (no employee) ────────────────────────────────────
+
+_SAFE_GLOBALS = {"int": int, "float": float, "round": round, "abs": abs, "min": min, "max": max, "flt": flt}
+
+
+def _fallback_solve(structure, target_field, target_amount, precision, selected, overrides=None):
+ overrides = overrides or {}
+ tol = 1 if precision == 0 else 0.05
+ lo, hi = 0.0, max(target_amount, 1)
+ best, best_diff = None, None
+
+ for i in range(20):
+ r = _fallback_calc(structure, hi, precision, selected, overrides)
+ val = flt(r[target_field], precision)
+ if val >= target_amount:
+ best, best_diff = r, abs(val - target_amount)
+ break
+ if i > 2 and val == 0:
+ return _empty_result()
+ hi *= 2
+ else:
+ best = r
+ best_diff = abs(flt(r[target_field], precision) - target_amount)
+
+ for _ in range(60):
+ mid = (lo + hi) / 2
+ r = _fallback_calc(structure, mid, precision, selected, overrides)
+ diff = flt(r[target_field], precision) - target_amount
+ if abs(diff) < (best_diff or float("inf")):
+ best, best_diff = r, abs(diff)
+ if abs(diff) <= tol:
+ break
+ if diff < 0:
+ lo = mid
+ else:
+ hi = mid
+
+ base_int = int(round(flt(best["base"], precision)))
+ for offset in range(-3, 4):
+ cand = base_int + offset
+ if cand < 0:
+ continue
+ r = _fallback_calc(structure, cand, precision, selected, overrides)
+ d = abs(flt(r[target_field], precision) - target_amount)
+ if d < best_diff or (d == best_diff and flt(r[target_field], precision) >= flt(best[target_field], precision)):
+ best, best_diff = r, d
+
+ return best
+
+
+def _fallback_calc(structure, base_amount, precision, selected, overrides=None):
+ overrides = overrides or {}
+ rows = {
+ "earnings": [_norm_row(r, "Earning", precision) for r in structure.earnings],
+ "deductions": [_norm_row(r, "Deduction", precision) for r in structure.deductions],
+ }
+ gross_pay = net_pay = total_ded = 0.0
+ prev = None
+
+ for _ in range(10):
+ ctx = {"base": flt(base_amount, precision), "gross_pay": gross_pay, "net_pay": net_pay, "total_deductions": total_ded}
+ for r in rows["earnings"] + rows["deductions"]:
+ if r.abbr:
+ ctx[r.abbr] = flt(r.amount, precision)
+ ctx[f"{r.abbr}_amount"] = flt(r.amount, precision)
+ _add_missing_ctx(ctx, rows["earnings"] + rows["deductions"])
+
+ earnings = _eval_rows(rows["earnings"], ctx, precision, selected, overrides)
+ gross_pay = flt(sum(r.amount for r in earnings if _incl_earning(r, selected)), precision)
+ ctx["gross_pay"] = gross_pay
+ for r in earnings:
+ if r.abbr:
+ ctx[r.abbr] = flt(r.amount, precision)
+ ctx[f"{r.abbr}_amount"] = flt(r.amount, precision)
+
+ deductions = _eval_rows(rows["deductions"], ctx, precision, selected)
+ total_ded = flt(sum(r.amount for r in deductions if _incl_deduction(r, selected)), precision)
+ net_pay = flt(gross_pay - total_ded, precision)
+
+ state = (gross_pay, total_ded, net_pay, tuple(r.amount for r in earnings + deductions))
+ rows = {"earnings": earnings, "deductions": deductions}
+ if state == prev:
+ break
+ prev = state
+
+ # Sum amounts by component name (handles duplicates like PAYE tiers)
+ amounts = {}
+ comp_types = {}
+ for r in earnings + deductions:
+ name = cstr(r.salary_component)
+ if name:
+ amounts[name] = flt(amounts.get(name, 0) + flt(r.amount, precision), precision)
+ comp_types[name] = r.component_type
+
+ # Always include all selected components so they appear in the preview
+ seen = set()
+ result_earnings, result_deductions = [], []
+ for c in selected:
+ if c in seen:
+ continue
+ seen.add(c)
+ entry = {"salary_component": c, "amount": flt(amounts.get(c, 0), precision)}
+ if comp_types.get(c) == "Earning":
+ result_earnings.append(entry)
+ elif comp_types.get(c) == "Deduction":
+ result_deductions.append(entry)
+
+ return {
+ "base": flt(base_amount, precision),
+ "gross_pay": gross_pay,
+ "net_pay": net_pay,
+ "total_deductions": total_ded,
+ "earnings": result_earnings,
+ "deductions": result_deductions,
+ }
+
+
+def _norm_row(row, ctype, precision):
+ return frappe._dict(
+ salary_component=cstr(row.salary_component), abbr=cstr(row.abbr), component_type=ctype,
+ condition=cstr(row.condition), formula=cstr(row.formula),
+ amount=flt(row.amount, precision), amount_based_on_formula=cint(row.amount_based_on_formula),
+ do_not_include_in_total=cint(row.do_not_include_in_total), statistical_component=cint(row.statistical_component),
+ )
+
+
+def _add_missing_ctx(ctx, rows):
+ """Default any undefined variable referenced in conditions/formulas.
+
+ Condition variables (used in boolean expressions like ``x == 1``) default
+ to 1 so that components are assumed applicable when no employee context is
+ available. Formula-only variables default to 0 so they don't inflate
+ amounts.
+ """
+ # Collect all tokens used in conditions vs formulas
+ cond_tokens = set()
+ formula_tokens = set()
+ for r in rows:
+ if r.condition:
+ cond_tokens.update(re.findall(r"\b[A-Za-z_][A-Za-z0-9_]*\b", r.condition))
+ if r.formula:
+ formula_tokens.update(re.findall(r"\b[A-Za-z_][A-Za-z0-9_]*\b", r.formula))
+
+ # Python keywords and safe_eval builtins to skip
+ skip = {"and", "or", "not", "if", "else", "True", "False", "None",
+ "int", "float", "round", "abs", "min", "max", "flt"}
+
+ for tok in cond_tokens:
+ if tok not in skip:
+ ctx.setdefault(tok, 1) # assume applicable
+
+ for tok in formula_tokens - cond_tokens:
+ if tok not in skip:
+ ctx.setdefault(tok, 0) # don't inflate amounts
+
+
+def _eval_rows(rows, base_ctx, precision, selected, overrides=None):
+ overrides = overrides or {}
+ ctx = frappe._dict(base_ctx.copy())
+ result = []
+ for row in rows:
+ comp = cstr(row.salary_component)
+ if comp in overrides and row.component_type == "Earning" and not _is_base(row):
+ amt = flt(overrides[comp], precision)
+ elif not _should_eval(row, selected):
+ amt = 0
+ elif row.condition and not _safe_eval_cond(row.condition, ctx):
+ amt = 0
+ elif row.amount_based_on_formula and row.formula:
+ amt = _safe_eval_formula(row.formula, ctx, precision)
+ else:
+ amt = row.amount if not row.amount_based_on_formula else 0
+ row.amount = flt(amt, precision)
+ result.append(row)
+ if row.abbr:
+ ctx[row.abbr] = row.amount
+ ctx[f"{row.abbr}_amount"] = row.amount
+ return result
+
+
+def _safe_eval_formula(formula, ctx, precision):
+ try:
+ return flt(frappe.safe_eval(cstr(formula).strip(), eval_globals=_SAFE_GLOBALS, eval_locals=ctx), precision)
+ except Exception:
+ return 0
+
+
+def _safe_eval_cond(condition, ctx):
+ try:
+ return cint(frappe.safe_eval(cstr(condition).strip(), eval_globals=_SAFE_GLOBALS, eval_locals=ctx))
+ except Exception:
+ return False
+
+
+def _is_base(row):
+ f = cstr(row.formula).replace(" ", "").lower()
+ return row.abbr == "B" or cstr(row.salary_component).lower() == "basic" or f == "base"
+
+
+def _should_eval(row, selected):
+ return row.statistical_component or _is_base(row) or cstr(row.salary_component) in set(selected)
+
+
+def _incl_earning(row, selected):
+ if row.component_type != "Earning" or row.do_not_include_in_total or row.statistical_component:
+ return False
+ return _is_base(row) or cstr(row.salary_component) in set(selected)
+
+
+def _incl_deduction(row, selected):
+ if row.component_type != "Deduction" or row.do_not_include_in_total or row.statistical_component:
+ return False
+ return cstr(row.salary_component) in set(selected)
diff --git a/av_tools/av_tools/page/salary_calculator/salary_slip_preview.html b/av_tools/av_tools/page/salary_calculator/salary_slip_preview.html
new file mode 100644
index 0000000..e185ae9
--- /dev/null
+++ b/av_tools/av_tools/page/salary_calculator/salary_slip_preview.html
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
Earnings
+
+
+
+ | Component |
+ Amount ({{ currency }}) |
+
+
+
+ {% for e in earnings %}
+
+ | {{ e.salary_component }} |
+ {{ format_amount(e.amount) }} |
+
+ {% endfor %}
+
+
+
+ | Total Earnings |
+ {{ format_amount(total_earning) }} |
+
+
+
+
+
+
+
Deductions
+
+
+
+ | Component |
+ Amount ({{ currency }}) |
+
+
+
+ {% for d in deductions %}
+
+ | {{ d.salary_component }} |
+ {{ format_amount(d.amount) }} |
+
+ {% endfor %}
+ {% if not deductions %}
+
+ | No deductions |
+
+ {% endif %}
+
+
+
+ | Total Deductions |
+ {{ format_amount(total_deduction) }} |
+
+
+
+
+
+
+
+
+