Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions hrms/api/roster.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from hrms.hr.doctype.shift_assignment.shift_assignment import ShiftAssignment
from hrms.hr.doctype.shift_assignment_tool.shift_assignment_tool import create_shift_assignment
from hrms.hr.doctype.shift_schedule.shift_schedule import get_or_insert_shift_schedule
from hrms.telemetry import capture

ALLOWED_EMPLOYEE_FILTERS = {
"status",
Expand Down Expand Up @@ -105,6 +106,16 @@ def create_shift_schedule_assignment(
}
).insert()

capture(
"shift_schedule_assignment_created",
{
"frequency": frequency,
"status": status,
"has_end_date": bool(end_date),
"repeat_on_days": len(repeat_on_days or []),
},
)

if not end_date or date_diff(end_date, start_date) <= 90:
return shift_schedule_assignment.create_shifts(start_date, end_date)

Expand Down Expand Up @@ -153,6 +164,9 @@ def swap_shift(
else:
tgt_company = frappe.db.get_value("Employee", tgt_employee, "company")

# All guards passed and the swap is proceeding — capture only successful attempts.
capture("shift_swapped", {"mutual_swap": bool(tgt_shift)})

break_shift(src_shift_doc, src_date)
insert_shift(
tgt_employee,
Expand Down
21 changes: 20 additions & 1 deletion hrms/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,12 +210,30 @@
"hrms.overrides.employee_master.update_approver_role",
"hrms.overrides.employee_master.publish_update",
],
"after_insert": "hrms.overrides.employee_master.update_job_applicant_and_offer",
"after_insert": [
"hrms.overrides.employee_master.update_job_applicant_and_offer",
"hrms.telemetry.on_milestone_insert",
],
"on_trash": "hrms.overrides.employee_master.update_employee_transfer",
"after_delete": "hrms.overrides.employee_master.publish_update",
},
"Project": {"validate": "hrms.controllers.employee_boarding_controller.update_employee_boarding_status"},
"Task": {"on_update": "hrms.controllers.employee_boarding_controller.update_task"},
# ---- Usage telemetry: recurring feature usage (see hrms/telemetry.py) ----
"Leave Application": {"on_submit": "hrms.telemetry.on_leave_application_submit"},
"Expense Claim": {"on_submit": "hrms.telemetry.on_expense_claim_submit"},
"Attendance Request": {"on_submit": "hrms.telemetry.on_attendance_request_submit"},
"Shift Request": {"on_submit": "hrms.telemetry.on_shift_request_submit"},
"Employee Checkin": {"after_insert": "hrms.telemetry.on_employee_checkin"},
# ---- Activation telemetry: post-install setup funnel (first-time milestones) ----
"Shift Type": {"after_insert": "hrms.telemetry.on_milestone_insert"},
"Leave Type": {"after_insert": "hrms.telemetry.on_milestone_insert"},
"Salary Structure": {"after_insert": "hrms.telemetry.on_milestone_insert"},
"Job Opening": {"after_insert": "hrms.telemetry.on_milestone_insert"},
"Appraisal Cycle": {"after_insert": "hrms.telemetry.on_milestone_insert"},
"Employee Onboarding": {"after_insert": "hrms.telemetry.on_milestone_insert"},
"Salary Slip": {"on_submit": "hrms.telemetry.on_milestone_submit"},
"Payroll Entry": {"on_submit": "hrms.telemetry.on_milestone_submit"},
}

# Scheduled Tasks
Expand All @@ -240,6 +258,7 @@
"hrms.hr.doctype.interview.interview.send_daily_feedback_reminder",
"hrms.hr.doctype.shift_assignment.shift_assignment.mark_expired_shift_assignments_as_inactive",
"hrms.hr.doctype.job_opening.job_opening.close_expired_job_openings",
"hrms.telemetry.capture_daily_attendance_pulse",
],
"daily_long": [
"hrms.hr.doctype.leave_ledger_entry.leave_ledger_entry.process_expired_allocation",
Expand Down
3 changes: 2 additions & 1 deletion hrms/hr/doctype/expense_claim/expense_claim.json
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
"fieldname": "payable_account",
"fieldtype": "Link",
"label": "Payable Account",
"mandatory_depends_on": "eval:!doc.is_paid",
"options": "Account"
},
{
Expand Down Expand Up @@ -507,7 +508,7 @@
"idx": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-11-24 11:45:16.354788",
"modified": "2026-07-15 16:43:28.006860",
"modified_by": "Administrator",
"module": "HR",
"name": "Expense Claim",
Expand Down
3 changes: 0 additions & 3 deletions hrms/hr/doctype/expense_claim/expense_claim.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,6 @@ def on_discard(self):
self.db_set("approval_status", "Cancelled")

def before_submit(self):
if not self.payable_account and not self.is_paid:
frappe.throw(_("Payable Account is mandatory to submit an Expense Claim"))

self.validate_for_self_approval()

def publish_update(self):
Expand Down
1 change: 1 addition & 0 deletions hrms/public/js/hrms.bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ import "./templates/rating.html";
import "./utils";
import "./utils/payroll_utils";
import "./utils/leave_utils";
import "./utils/telemetry.js";
import "./salary_slip_deductions_report_filters.js";
157 changes: 157 additions & 0 deletions hrms/public/js/utils/telemetry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
frappe.provide("hrms.telemetry");

// HR workspaces shown on the desk sidebar (see hrms/hr/workspace + payroll/workspace).
const HR_WORKSPACES = new Set([
"HR",
"HR Setup",
"Leaves",
"Shift & Attendance",
"Expenses",
"Performance",
"Recruitment",
"Tenure",
"Payroll",
]);

// Key HR doctypes worth tracking navigation into. Kept explicit so we only ever
// emit HR-relevant events and never generic desk traffic.
const HR_DOCTYPES = new Set([
"Employee",
"Employee Checkin",
"Attendance",
"Attendance Request",
"Leave Application",
"Leave Allocation",
"Leave Type",
"Leave Policy",
"Shift Type",
"Shift Assignment",
"Shift Request",
"Expense Claim",
"Salary Structure",
"Salary Structure Assignment",
"Salary Slip",
"Payroll Entry",
"Payroll Period",
"Job Opening",
"Job Applicant",
"Job Offer",
"Interview",
"Appraisal",
"Appraisal Cycle",
"Appraisal Template",
"Employee Onboarding",
"Employee Separation",
]);

// HR + Payroll reports (query/script reports). Scoped explicitly so we learn which
// HR reports get used frequently, without capturing unrelated desk report traffic.
const HR_REPORTS = new Set([
"Monthly Attendance Sheet",
"Shift Attendance",
"Employees working on a holiday",
"Employee Leave Balance",
"Employee Leave Balance Summary",
"Leave Ledger",
"Employee Analytics",
"Employee Information",
"Employee Birthday",
"Employee Exits",
"Employee Advance Summary",
"Employee Hours Utilization Based On Timesheet",
"Recruitment Analytics",
"Appraisal Overview",
"Unpaid Expense Claim",
"Vehicle Expenses",
"Project Profitability",
"Salary Register",
"Salary Payments Based On Payment Mode",
"Salary Payments via ECS",
"Bank Remittance",
"Employee CTC Break-up",
"Accrued Earnings Report",
"Income Tax Computation",
"Income Tax Deductions",
"Professional Tax Deductions",
"Provident Fund Deductions",
"Daily Work Summary Replies",
]);

function hr_capture(event, props) {
if (!frappe.telemetry?.enabled) return;
try {
frappe.telemetry.capture(event, "hrms", props || {});
} catch (e) {
// telemetry must never break navigation
}
}

// Turn the current route into a semantic HR event, or null if it's not HR.
function classify(route) {
if (!route || !route.length) return null;
const head = route[0];

if (head === "Workspaces") {
// ["Workspaces", Name] or ["Workspaces", "private", Name]
const name = route[route.length - 1];
if (HR_WORKSPACES.has(name)) {
return { event: "viewed_workspace", props: { workspace: name } };
}
return null;
}

if (head === "List" && HR_DOCTYPES.has(route[1])) {
return {
event: "viewed_list",
props: { doctype: route[1], view: route[2] || "List" },
};
}

if (head === "Form" && HR_DOCTYPES.has(route[1])) {
const name = route[2];
const is_new = typeof name === "string" && name.startsWith("new-");
return {
event: is_new ? "started_creating" : "viewed_form",
props: { doctype: route[1] },
};
}

if ((head === "query-report" || head === "report") && HR_REPORTS.has(route[1])) {
return { event: "viewed_report", props: { report: route[1] } };
}

return null;
}

function track_route() {
const hit = classify(frappe.get_route());
if (hit) hr_capture(hit.event, hit.props);
}

function track_landing() {
try {
if (sessionStorage.getItem("hrms_landing_tracked")) return;
sessionStorage.setItem("hrms_landing_tracked", "1");
} catch (e) {
// private mode / storage disabled — fall through and still capture once
}

const route = frappe.get_route() || [];
const hit = classify(route);
hr_capture("landed_on_desk", {
route_type: route[0] || "",
landed_in_hr: Boolean(hit),
...(hit ? hit.props : {}),
});
}

$(document).on("app_ready", function () {
if (!frappe.telemetry?.enabled) return;

// Defer to the next tick so the first route is fully resolved.
frappe.after_ajax(() => {
track_landing();
track_route();
frappe.router.on("change", track_route);
});
});
Loading
Loading