hr_lite 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +33 -0
- data/MIT-LICENSE +20 -0
- data/README.md +166 -0
- data/Rakefile +3 -0
- data/app/assets/javascripts/hr_lite/geo_punch.js +61 -0
- data/app/assets/javascripts/hr_lite/mention.js +100 -0
- data/app/assets/stylesheets/hr_lite/application.css +15 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +239 -0
- data/app/controllers/hr_lite/admin/appraisals_controller.rb +74 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +79 -0
- data/app/controllers/hr_lite/admin/audit_logs_controller.rb +9 -0
- data/app/controllers/hr_lite/admin/base_controller.rb +16 -0
- data/app/controllers/hr_lite/admin/designation_changes_controller.rb +33 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +53 -0
- data/app/controllers/hr_lite/admin/holidays_controller.rb +70 -0
- data/app/controllers/hr_lite/admin/leadership_controller.rb +18 -0
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +41 -0
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +41 -0
- data/app/controllers/hr_lite/admin/leave_types_controller.rb +54 -0
- data/app/controllers/hr_lite/admin/office_locations_controller.rb +46 -0
- data/app/controllers/hr_lite/admin/overview_controller.rb +12 -0
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +93 -0
- data/app/controllers/hr_lite/admin/salary_slips_controller.rb +44 -0
- data/app/controllers/hr_lite/admin/salary_structures_controller.rb +49 -0
- data/app/controllers/hr_lite/admin/settings_controller.rb +18 -0
- data/app/controllers/hr_lite/application_controller.rb +76 -0
- data/app/controllers/hr_lite/appraisals_controller.rb +18 -0
- data/app/controllers/hr_lite/attendance_controller.rb +37 -0
- data/app/controllers/hr_lite/calendar_controller.rb +20 -0
- data/app/controllers/hr_lite/career_controller.rb +9 -0
- data/app/controllers/hr_lite/employee_profiles_controller.rb +9 -0
- data/app/controllers/hr_lite/holidays_controller.rb +9 -0
- data/app/controllers/hr_lite/home_controller.rb +7 -0
- data/app/controllers/hr_lite/kudos_controller.rb +36 -0
- data/app/controllers/hr_lite/leave_balances_controller.rb +11 -0
- data/app/controllers/hr_lite/leave_requests_controller.rb +56 -0
- data/app/controllers/hr_lite/salary_slips_controller.rb +39 -0
- data/app/controllers/hr_lite/users_controller.rb +9 -0
- data/app/helpers/hr_lite/application_helper.rb +105 -0
- data/app/jobs/hr_lite/application_job.rb +4 -0
- data/app/jobs/hr_lite/daily_digest_job.rb +33 -0
- data/app/jobs/hr_lite/leave_year_rollover_job.rb +29 -0
- data/app/mailers/hr_lite/application_mailer.rb +5 -0
- data/app/mailers/hr_lite/event_mailer.rb +33 -0
- data/app/models/concerns/hr_lite/audited.rb +70 -0
- data/app/models/concerns/hr_lite/encrypted_money.rb +32 -0
- data/app/models/hr_lite/application_record.rb +5 -0
- data/app/models/hr_lite/appraisal.rb +89 -0
- data/app/models/hr_lite/attendance_record.rb +41 -0
- data/app/models/hr_lite/audit_log.rb +17 -0
- data/app/models/hr_lite/designation_change.rb +52 -0
- data/app/models/hr_lite/employee_profile.rb +72 -0
- data/app/models/hr_lite/holiday.rb +18 -0
- data/app/models/hr_lite/kudo.rb +54 -0
- data/app/models/hr_lite/kudo_mention.rb +8 -0
- data/app/models/hr_lite/leave_balance.rb +42 -0
- data/app/models/hr_lite/leave_request.rb +207 -0
- data/app/models/hr_lite/leave_type.rb +22 -0
- data/app/models/hr_lite/office_location.rb +21 -0
- data/app/models/hr_lite/payroll_run.rb +112 -0
- data/app/models/hr_lite/salary_slip.rb +78 -0
- data/app/models/hr_lite/salary_structure.rb +49 -0
- data/app/models/hr_lite/setting.rb +14 -0
- data/app/services/hr_lite/attendance_puncher.rb +103 -0
- data/app/services/hr_lite/attendance_summary.rb +98 -0
- data/app/services/hr_lite/calculators/esi.rb +25 -0
- data/app/services/hr_lite/calculators/pf.rb +32 -0
- data/app/services/hr_lite/calculators/professional_tax.rb +20 -0
- data/app/services/hr_lite/calculators/proration.rb +30 -0
- data/app/services/hr_lite/calculators/tds.rb +74 -0
- data/app/services/hr_lite/day_status.rb +49 -0
- data/app/services/hr_lite/leave_day_counter.rb +24 -0
- data/app/services/hr_lite/overview_query.rb +39 -0
- data/app/services/hr_lite/payroll_run_processor.rb +46 -0
- data/app/services/hr_lite/pdf_renderer.rb +23 -0
- data/app/services/hr_lite/slip_builder.rb +106 -0
- data/app/services/hr_lite/working_calendar.rb +41 -0
- data/app/views/hr_lite/admin/appraisals/_form.html.erb +29 -0
- data/app/views/hr_lite/admin/appraisals/edit.html.erb +6 -0
- data/app/views/hr_lite/admin/appraisals/new.html.erb +6 -0
- data/app/views/hr_lite/admin/attendances/index.html.erb +49 -0
- data/app/views/hr_lite/admin/attendances/show.html.erb +55 -0
- data/app/views/hr_lite/admin/audit_logs/index.html.erb +39 -0
- data/app/views/hr_lite/admin/designation_changes/new.html.erb +19 -0
- data/app/views/hr_lite/admin/employees/_form.html.erb +40 -0
- data/app/views/hr_lite/admin/employees/edit.html.erb +3 -0
- data/app/views/hr_lite/admin/employees/index.html.erb +40 -0
- data/app/views/hr_lite/admin/employees/new.html.erb +3 -0
- data/app/views/hr_lite/admin/employees/show.html.erb +75 -0
- data/app/views/hr_lite/admin/holidays/index.html.erb +59 -0
- data/app/views/hr_lite/admin/leave_balances/index.html.erb +58 -0
- data/app/views/hr_lite/admin/leave_requests/index.html.erb +35 -0
- data/app/views/hr_lite/admin/leave_requests/show.html.erb +39 -0
- data/app/views/hr_lite/admin/leave_types/_form.html.erb +27 -0
- data/app/views/hr_lite/admin/leave_types/edit.html.erb +10 -0
- data/app/views/hr_lite/admin/leave_types/index.html.erb +39 -0
- data/app/views/hr_lite/admin/leave_types/new.html.erb +3 -0
- data/app/views/hr_lite/admin/office_locations/_form.html.erb +18 -0
- data/app/views/hr_lite/admin/office_locations/edit.html.erb +10 -0
- data/app/views/hr_lite/admin/office_locations/index.html.erb +30 -0
- data/app/views/hr_lite/admin/office_locations/new.html.erb +3 -0
- data/app/views/hr_lite/admin/overview/index.html.erb +72 -0
- data/app/views/hr_lite/admin/payroll_runs/index.html.erb +33 -0
- data/app/views/hr_lite/admin/payroll_runs/new.html.erb +20 -0
- data/app/views/hr_lite/admin/payroll_runs/show.html.erb +76 -0
- data/app/views/hr_lite/admin/salary_slips/show.html.erb +42 -0
- data/app/views/hr_lite/admin/salary_structures/_form.html.erb +25 -0
- data/app/views/hr_lite/admin/salary_structures/edit.html.erb +6 -0
- data/app/views/hr_lite/admin/salary_structures/new.html.erb +6 -0
- data/app/views/hr_lite/admin/settings/edit.html.erb +18 -0
- data/app/views/hr_lite/appraisals/index.html.erb +22 -0
- data/app/views/hr_lite/appraisals/show.html.erb +32 -0
- data/app/views/hr_lite/attendance/_month_grid.html.erb +41 -0
- data/app/views/hr_lite/attendance/_punch_card.html.erb +41 -0
- data/app/views/hr_lite/attendance/show.html.erb +22 -0
- data/app/views/hr_lite/calendar/show.html.erb +45 -0
- data/app/views/hr_lite/career/show.html.erb +49 -0
- data/app/views/hr_lite/employee_profiles/show.html.erb +22 -0
- data/app/views/hr_lite/event_mailer/event.html.erb +10 -0
- data/app/views/hr_lite/event_mailer/event.text.erb +10 -0
- data/app/views/hr_lite/event_mailer/leadership.html.erb +27 -0
- data/app/views/hr_lite/event_mailer/leadership.text.erb +16 -0
- data/app/views/hr_lite/holidays/_next_holiday.html.erb +12 -0
- data/app/views/hr_lite/holidays/index.html.erb +30 -0
- data/app/views/hr_lite/home/index.html.erb +25 -0
- data/app/views/hr_lite/kudos/_kudo.html.erb +17 -0
- data/app/views/hr_lite/kudos/index.html.erb +39 -0
- data/app/views/hr_lite/leave_balances/index.html.erb +19 -0
- data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +17 -0
- data/app/views/hr_lite/leave_requests/index.html.erb +37 -0
- data/app/views/hr_lite/leave_requests/new.html.erb +36 -0
- data/app/views/hr_lite/leave_requests/show.html.erb +25 -0
- data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +48 -0
- data/app/views/hr_lite/salary_slips/index.html.erb +29 -0
- data/app/views/hr_lite/salary_slips/pdf.html.erb +72 -0
- data/app/views/hr_lite/salary_slips/show.html.erb +12 -0
- data/app/views/hr_lite/shared/_form_errors.html.erb +10 -0
- data/app/views/hr_lite/shared/_pagination.html.erb +11 -0
- data/app/views/layouts/hr_lite/application.html.erb +56 -0
- data/app/views/layouts/hr_lite/mailer.html.erb +30 -0
- data/app/views/layouts/hr_lite/mailer.text.erb +6 -0
- data/app/views/layouts/hr_lite/pdf.html.erb +26 -0
- data/config/routes.rb +57 -0
- data/db/migrate/20260718190726_create_hr_lite_kudos.rb +14 -0
- data/db/migrate/20260718190727_create_hr_lite_kudo_mentions.rb +12 -0
- data/db/migrate/20260718190728_create_hr_lite_audit_logs.rb +15 -0
- data/db/migrate/20260718192342_create_hr_lite_office_locations.rb +13 -0
- data/db/migrate/20260718192343_create_hr_lite_attendance_records.rb +27 -0
- data/db/migrate/20260718193045_create_hr_lite_holidays.rb +14 -0
- data/db/migrate/20260718193046_create_hr_lite_leave_types.rb +20 -0
- data/db/migrate/20260718193047_create_hr_lite_leave_balances.rb +17 -0
- data/db/migrate/20260718193048_create_hr_lite_leave_requests.rb +23 -0
- data/db/migrate/20260718193049_create_hr_lite_settings.rb +10 -0
- data/db/migrate/20260718194534_create_hr_lite_employee_profiles.rb +27 -0
- data/db/migrate/20260718194535_create_hr_lite_salary_structures.rb +23 -0
- data/db/migrate/20260718194536_create_hr_lite_payroll_runs.rb +20 -0
- data/db/migrate/20260718194537_create_hr_lite_salary_slips.rb +28 -0
- data/db/migrate/20260718195953_create_hr_lite_appraisals.rb +22 -0
- data/db/migrate/20260718195954_create_hr_lite_designation_changes.rb +17 -0
- data/lib/generators/hr_lite/install/install_generator.rb +29 -0
- data/lib/generators/hr_lite/install/templates/AFTER_INSTALL +35 -0
- data/lib/generators/hr_lite/install/templates/initializer.rb +45 -0
- data/lib/hr_lite/configuration.rb +60 -0
- data/lib/hr_lite/current.rb +7 -0
- data/lib/hr_lite/engine.rb +39 -0
- data/lib/hr_lite/geo.rb +22 -0
- data/lib/hr_lite/mention_parser.rb +18 -0
- data/lib/hr_lite/money.rb +33 -0
- data/lib/hr_lite/notifications.rb +99 -0
- data/lib/hr_lite/seeds.rb +42 -0
- data/lib/hr_lite/statutory_rate_card.rb +60 -0
- data/lib/hr_lite/version.rb +3 -0
- data/lib/hr_lite.rb +104 -0
- data/lib/tasks/hr_lite_tasks.rake +8 -0
- metadata +250 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# All host integration points. Every attribute has a working default so the
|
|
3
|
+
# engine boots in any Rails app; a real host overrides what it needs in an
|
|
4
|
+
# initializer. See README for the full annotated example.
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :user_class, :parent_controller, :current_user_method,
|
|
7
|
+
:authenticate_method, :admin_check, :display_name_method,
|
|
8
|
+
:employees_scope, :mentionable_users, :notify, :render_pdf, :company,
|
|
9
|
+
:time_zone, :currency_symbol, :on_designation_change,
|
|
10
|
+
:leadership_emails, :leadership_check, :extra_stylesheets,
|
|
11
|
+
:mailer_from, :public_url_base, :notification_matrix, :back_link
|
|
12
|
+
|
|
13
|
+
# 0.1.0 pre-release name for public_url_base; kept as an alias so early
|
|
14
|
+
# adopters' initializers don't break.
|
|
15
|
+
alias_method :mail_link_base, :public_url_base
|
|
16
|
+
alias_method :mail_link_base=, :public_url_base=
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@user_class = "User"
|
|
20
|
+
@parent_controller = "ActionController::Base"
|
|
21
|
+
@current_user_method = :current_user
|
|
22
|
+
@authenticate_method = :authenticate_user!
|
|
23
|
+
@admin_check = ->(user) { user.respond_to?(:admin?) && user.admin? }
|
|
24
|
+
@display_name_method = :display_name
|
|
25
|
+
@employees_scope = -> { HrLite.user_klass.all }
|
|
26
|
+
@mentionable_users = ->(query) { HrLite.default_mentionable_users(query) }
|
|
27
|
+
@notify = ->(user:, kind:, title:, body:, path:) { }
|
|
28
|
+
@render_pdf = nil
|
|
29
|
+
@company = -> { { name: "Company", address: nil, logo_path: nil } }
|
|
30
|
+
@time_zone = "Asia/Kolkata"
|
|
31
|
+
@currency_symbol = "₹"
|
|
32
|
+
@on_designation_change = ->(user, designation) { }
|
|
33
|
+
@leadership_emails = []
|
|
34
|
+
@leadership_check = ->(user) do
|
|
35
|
+
emails = HrLite.config.leadership_emails.map { |e| e.to_s.downcase.strip }
|
|
36
|
+
emails.include?(user.email.to_s.downcase)
|
|
37
|
+
end
|
|
38
|
+
@extra_stylesheets = [] # host stylesheets linked AFTER hr_lite.css (CSS-var overrides)
|
|
39
|
+
@mailer_from = "hr@example.com"
|
|
40
|
+
@public_url_base = nil # e.g. "https://hr.example.com" — enables email links + HrLite.public_url
|
|
41
|
+
@notification_matrix = nil # resolved lazily to Notifications::DEFAULT_MATRIX
|
|
42
|
+
@back_link = nil # optional {label:, url:} for the shell nav
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
# Default @mention source: name/email prefix match on the host user table.
|
|
48
|
+
# Uses LOWER(...) LIKE so it works on sqlite and postgres alike; hosts
|
|
49
|
+
# with pg_trgm or scopes of their own override config.mentionable_users.
|
|
50
|
+
def default_mentionable_users(query)
|
|
51
|
+
q = "%#{ActiveRecord::Base.sanitize_sql_like(query.to_s.downcase)}%"
|
|
52
|
+
klass = user_klass
|
|
53
|
+
columns = %w[name email].select { |c| klass.column_names.include?(c) }
|
|
54
|
+
return klass.none if columns.empty?
|
|
55
|
+
|
|
56
|
+
where_sql = columns.map { |c| "LOWER(#{klass.table_name}.#{c}) LIKE :q" }.join(" OR ")
|
|
57
|
+
klass.where(where_sql, q: q).order(:id).limit(8)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace HrLite
|
|
4
|
+
|
|
5
|
+
config.generators do |g|
|
|
6
|
+
g.test_framework :rspec
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Make the engine's plain CSS/JS visible to whichever asset pipeline the
|
|
10
|
+
# host runs (Propshaft or Sprockets both read config.assets.paths).
|
|
11
|
+
initializer "hr_lite.assets" do |app|
|
|
12
|
+
if app.config.respond_to?(:assets)
|
|
13
|
+
app.config.assets.paths << root.join("app/assets/stylesheets")
|
|
14
|
+
app.config.assets.paths << root.join("app/assets/javascripts")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Serve the engine's migrations straight from the gem: `bin/rails
|
|
19
|
+
# db:migrate` in the host just works, and gem upgrades ship their
|
|
20
|
+
# migrations automatically — nothing to copy, nothing to drift.
|
|
21
|
+
#
|
|
22
|
+
# Skipped when the host already copied them via
|
|
23
|
+
# `hr_lite:install:migrations` (files named *.hr_lite.rb) — otherwise the
|
|
24
|
+
# same tables would be created twice under different version stamps.
|
|
25
|
+
initializer "hr_lite.migrations" do |app|
|
|
26
|
+
next if app.root.to_s == root.to_s
|
|
27
|
+
next unless HrLite::Engine.append_migrations?(app)
|
|
28
|
+
|
|
29
|
+
config.paths["db/migrate"].expanded.each do |path|
|
|
30
|
+
app.config.paths["db/migrate"] << path
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.append_migrations?(app)
|
|
35
|
+
host_migrate_dirs = app.config.paths["db/migrate"].expanded
|
|
36
|
+
host_migrate_dirs.none? { |dir| Dir.glob(File.join(dir, "*.hr_lite.rb")).any? }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/hr_lite/geo.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Pure-Ruby haversine — no geocoding gem needed to answer "how far is
|
|
3
|
+
# this punch from the office?" at office-radius precision.
|
|
4
|
+
module Geo
|
|
5
|
+
EARTH_RADIUS_M = 6_371_000.0
|
|
6
|
+
|
|
7
|
+
def self.distance_m(lat1, lng1, lat2, lng2)
|
|
8
|
+
rlat1 = deg2rad(lat1)
|
|
9
|
+
rlat2 = deg2rad(lat2)
|
|
10
|
+
dlat = deg2rad(lat2.to_f - lat1.to_f)
|
|
11
|
+
dlng = deg2rad(lng2.to_f - lng1.to_f)
|
|
12
|
+
|
|
13
|
+
a = Math.sin(dlat / 2)**2 + Math.cos(rlat1) * Math.cos(rlat2) * Math.sin(dlng / 2)**2
|
|
14
|
+
c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
|
15
|
+
EARTH_RADIUS_M * c
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.deg2rad(deg)
|
|
19
|
+
deg.to_f * Math::PI / 180
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Kudos messages carry mentions as visible plain-text markers:
|
|
3
|
+
# "Great save @[Asha Rao](42) during the outage"
|
|
4
|
+
# The picker inserts them; this parser extracts the ids server-side.
|
|
5
|
+
module MentionParser
|
|
6
|
+
MARKER = /@\[([^\]\n]{1,80})\]\((\d+)\)/
|
|
7
|
+
LIMIT = 10 # notification-spam cap
|
|
8
|
+
|
|
9
|
+
def self.user_ids(text)
|
|
10
|
+
text.to_s.scan(MARKER).map { |(_name, id)| id.to_i }.uniq.first(LIMIT)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Bell/email bodies show "@Asha Rao", never the raw marker.
|
|
14
|
+
def self.strip_markers(text)
|
|
15
|
+
text.to_s.gsub(MARKER) { "@#{Regexp.last_match(1)}" }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Central rounding discipline. Each statutory line rounds by its own rule
|
|
3
|
+
# exactly once; nets are plain subtraction of already-rounded lines.
|
|
4
|
+
module Money
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def d(value)
|
|
8
|
+
return BigDecimal(0) if value.nil?
|
|
9
|
+
|
|
10
|
+
value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Nearest rupee, half-up (PF, TDS monthly).
|
|
14
|
+
def round_rupee(value)
|
|
15
|
+
d(value).round(0, BigDecimal::ROUND_HALF_UP)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Next rupee (ESIC rounds contributions UP).
|
|
19
|
+
def ceil_rupee(value)
|
|
20
|
+
d(value).ceil(0)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Paise precision (earnings proration).
|
|
24
|
+
def round2(value)
|
|
25
|
+
d(value).round(2)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Section 288B: annual tax to the nearest ten rupees.
|
|
29
|
+
def round_to_10(value)
|
|
30
|
+
(d(value) / 10).round(0, BigDecimal::ROUND_HALF_UP) * 10
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Central event bus. Domain code publishes an event; the per-event channel
|
|
3
|
+
# row (host-overridable via config.notification_matrix) decides which
|
|
4
|
+
# channels fire. Channel semantics:
|
|
5
|
+
#
|
|
6
|
+
# bell: in-app notification (config.notify) to `bell_to` users
|
|
7
|
+
# email: EventMailer to each `email_to` user
|
|
8
|
+
# leadership_email: ONE email to every configured leadership address
|
|
9
|
+
# leadership_bell: in-app notification to each leadership user record
|
|
10
|
+
#
|
|
11
|
+
# Recipients are computed by the publishing site (it knows the domain);
|
|
12
|
+
# the matrix is purely the on/off routing table.
|
|
13
|
+
module Notifications
|
|
14
|
+
DEFAULT_MATRIX = {
|
|
15
|
+
"leave.requested" => { bell: true, email: false, leadership_email: true, leadership_bell: true },
|
|
16
|
+
"leave.approved" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
|
17
|
+
"leave.rejected" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
|
18
|
+
"leave.cancelled" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
|
|
19
|
+
"attendance.flagged" => { bell: true, email: false, leadership_email: false, leadership_bell: false },
|
|
20
|
+
"attendance.regularized" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
|
21
|
+
"payroll.finalized" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
|
|
22
|
+
"payroll.published" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
|
23
|
+
"kudos.mentioned" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
24
|
+
"appraisal.shared" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
|
25
|
+
"promotion.recorded" => { bell: true, email: true, leadership_email: true, leadership_bell: true },
|
|
26
|
+
"policy.changed" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
|
|
27
|
+
"digest.daily" => { bell: false, email: false, leadership_email: true, leadership_bell: false }
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def matrix
|
|
32
|
+
HrLite.config.notification_matrix || DEFAULT_MATRIX
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def publish(event, title:, body: nil, path: nil, bell_to: [], email_to: [], lines: [], diff: nil)
|
|
36
|
+
row = matrix[event.to_s]
|
|
37
|
+
unless row
|
|
38
|
+
Rails.logger.warn("[hr_lite] unknown notification event #{event}")
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
deliver_bells(event, row, bell_to, title, body, path)
|
|
43
|
+
deliver_emails(row, email_to, title, body, path, lines)
|
|
44
|
+
deliver_leadership_email(event, row, title, body, path, lines, diff)
|
|
45
|
+
deliver_leadership_bells(event, row, bell_to, title, body, path)
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def deliver_bells(event, row, bell_to, title, body, path)
|
|
52
|
+
return unless row[:bell]
|
|
53
|
+
|
|
54
|
+
Array(bell_to).compact.uniq.each do |user|
|
|
55
|
+
HrLite.notify(user: user, kind: event.to_s, title: title, body: body, path: path)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def deliver_emails(row, email_to, title, body, path, lines)
|
|
60
|
+
return unless row[:email]
|
|
61
|
+
|
|
62
|
+
Array(email_to).compact.uniq.each do |user|
|
|
63
|
+
next if user.email.blank?
|
|
64
|
+
|
|
65
|
+
EventMailer.event(to: user.email, subject: title, heading: title,
|
|
66
|
+
body: body, lines: lines, path: path).deliver_later
|
|
67
|
+
end
|
|
68
|
+
rescue => e
|
|
69
|
+
Rails.logger.error("[hr_lite] event email failed: #{e.class}: #{e.message}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def deliver_leadership_email(event, row, title, body, path, lines, diff)
|
|
73
|
+
return unless row[:leadership_email]
|
|
74
|
+
|
|
75
|
+
recipients = HrLite.config.leadership_emails.map { |e| e.to_s.strip }.reject(&:empty?)
|
|
76
|
+
return if recipients.empty?
|
|
77
|
+
|
|
78
|
+
EventMailer.leadership(to: recipients, subject: "[HR] #{title}", heading: title,
|
|
79
|
+
body: body, lines: lines, diff: diff, path: path, event: event.to_s)
|
|
80
|
+
.deliver_later
|
|
81
|
+
rescue => e
|
|
82
|
+
Rails.logger.error("[hr_lite] leadership email failed: #{e.class}: #{e.message}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def deliver_leadership_bells(event, row, bell_to, title, body, path)
|
|
86
|
+
return unless row[:leadership_bell]
|
|
87
|
+
|
|
88
|
+
already = Array(bell_to).compact
|
|
89
|
+
HrLite.leadership_users.each do |leader|
|
|
90
|
+
next if already.any? { |u| u.id == leader.id }
|
|
91
|
+
|
|
92
|
+
HrLite.notify(user: leader, kind: event.to_s, title: title, body: body, path: path)
|
|
93
|
+
end
|
|
94
|
+
rescue => e
|
|
95
|
+
Rails.logger.error("[hr_lite] leadership bell failed: #{e.class}: #{e.message}")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Idempotent reference data. Hosts run it on every deploy
|
|
3
|
+
# (`rake hr_lite:seed`); re-runs never overwrite operator edits.
|
|
4
|
+
module Seeds
|
|
5
|
+
DEFAULT_LEAVE_TYPES = [
|
|
6
|
+
{ code: "CL", name: "Casual leave", color: "#0ea5e9", paid: true, annual_quota: 12, accrual: "monthly", carry_forward_cap: 0, position: 1 },
|
|
7
|
+
{ code: "SL", name: "Sick leave", color: "#f59e0b", paid: true, annual_quota: 6, accrual: "yearly_upfront", carry_forward_cap: 0, position: 2 },
|
|
8
|
+
{ code: "EL", name: "Earned leave", color: "#10b981", paid: true, annual_quota: 15, accrual: "monthly", carry_forward_cap: 30, position: 3 },
|
|
9
|
+
{ code: "LWP", name: "Leave without pay", color: "#6b7280", paid: false, annual_quota: nil, accrual: "yearly_upfront", carry_forward_cap: 0, position: 4 },
|
|
10
|
+
{ code: "CO", name: "Comp off", color: "#8b5cf6", paid: true, annual_quota: 0, accrual: "yearly_upfront", carry_forward_cap: 0, position: 5 }
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def self.run!
|
|
14
|
+
seed_leave_types! + seed_holidays!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.seed_leave_types!
|
|
18
|
+
DEFAULT_LEAVE_TYPES.filter_map do |attrs|
|
|
19
|
+
next if LeaveType.exists?(code: attrs[:code])
|
|
20
|
+
|
|
21
|
+
LeaveType.create!(attrs)
|
|
22
|
+
"leave_type #{attrs[:code]}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Only the three fixed-date national holidays — festival dates shift
|
|
27
|
+
# every year and belong to the admin bulk-paste flow.
|
|
28
|
+
def self.seed_holidays!
|
|
29
|
+
year = Date.current.year
|
|
30
|
+
[
|
|
31
|
+
[ Date.new(year, 1, 26), "Republic Day" ],
|
|
32
|
+
[ Date.new(year, 8, 15), "Independence Day" ],
|
|
33
|
+
[ Date.new(year, 10, 2), "Gandhi Jayanti" ]
|
|
34
|
+
].filter_map do |date, name|
|
|
35
|
+
next if Holiday.exists?(date: date)
|
|
36
|
+
|
|
37
|
+
Holiday.create!(date: date, name: name)
|
|
38
|
+
"holiday #{name} #{year}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Config-driven statutory rates, keyed by effective date — a budget change
|
|
3
|
+
# is a one-hash edit that gets code review and a spec diff, never an
|
|
4
|
+
# inline-constant hunt.
|
|
5
|
+
#
|
|
6
|
+
# VERIFY WITH A CA before the first run of any new financial year; the
|
|
7
|
+
# FY 2026-27 card ships with FY 2025-26 figures pending confirmation of
|
|
8
|
+
# the Feb 2026 Finance Act.
|
|
9
|
+
module StatutoryRateCard
|
|
10
|
+
def self.r(value) = BigDecimal(value.to_s)
|
|
11
|
+
private_class_method :r
|
|
12
|
+
|
|
13
|
+
CARDS = {
|
|
14
|
+
Date.new(2025, 4, 1) => {
|
|
15
|
+
pf: {
|
|
16
|
+
employee_rate: r("0.12"), employer_rate: r("0.12"), eps_rate: r("0.0833"),
|
|
17
|
+
wage_ceiling: r("15000"), eps_wage_ceiling: r("15000"),
|
|
18
|
+
edli_rate: r("0.005"), edli_ceiling: r("15000"), admin_rate: r("0.005")
|
|
19
|
+
},
|
|
20
|
+
esi: { employee_rate: r("0.0075"), employer_rate: r("0.0325"), gross_ceiling: r("21000") },
|
|
21
|
+
pt: {
|
|
22
|
+
# Neither UP nor Uttarakhand levies professional tax today; both
|
|
23
|
+
# ship empty (PT = 0). Karnataka included as a worked template.
|
|
24
|
+
"none" => [],
|
|
25
|
+
"uttar_pradesh" => [],
|
|
26
|
+
"uttarakhand" => [],
|
|
27
|
+
"karnataka" => [ { above: r("24999"), monthly: r("200") } ]
|
|
28
|
+
},
|
|
29
|
+
income_tax: {
|
|
30
|
+
"new" => {
|
|
31
|
+
standard_deduction: r("75000"), rebate_cap: r("1200000"), cess_rate: r("0.04"),
|
|
32
|
+
slabs: [
|
|
33
|
+
[ r("0"), r("400000"), r("0") ],
|
|
34
|
+
[ r("400000"), r("800000"), r("0.05") ],
|
|
35
|
+
[ r("800000"), r("1200000"), r("0.10") ],
|
|
36
|
+
[ r("1200000"), r("1600000"), r("0.15") ],
|
|
37
|
+
[ r("1600000"), r("2000000"), r("0.20") ],
|
|
38
|
+
[ r("2000000"), r("2400000"), r("0.25") ],
|
|
39
|
+
[ r("2400000"), nil, r("0.30") ]
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"old" => {
|
|
43
|
+
standard_deduction: r("50000"), rebate_cap: r("500000"), cess_rate: r("0.04"),
|
|
44
|
+
slabs: [
|
|
45
|
+
[ r("0"), r("250000"), r("0") ],
|
|
46
|
+
[ r("250000"), r("500000"), r("0.05") ],
|
|
47
|
+
[ r("500000"), r("1000000"), r("0.20") ],
|
|
48
|
+
[ r("1000000"), nil, r("0.30") ]
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
def self.for(period_month)
|
|
56
|
+
effective = CARDS.keys.sort.reverse.find { |date| date <= period_month }
|
|
57
|
+
effective ? CARDS[effective] : CARDS[CARDS.keys.min]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/hr_lite.rb
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
require "hr_lite/version"
|
|
2
|
+
require "hr_lite/engine"
|
|
3
|
+
require "hr_lite/configuration"
|
|
4
|
+
require "hr_lite/current"
|
|
5
|
+
require "hr_lite/mention_parser"
|
|
6
|
+
require "hr_lite/notifications"
|
|
7
|
+
require "hr_lite/seeds"
|
|
8
|
+
require "hr_lite/geo"
|
|
9
|
+
require "hr_lite/money"
|
|
10
|
+
require "hr_lite/statutory_rate_card"
|
|
11
|
+
|
|
12
|
+
module HrLite
|
|
13
|
+
class << self
|
|
14
|
+
def config
|
|
15
|
+
@config ||= Configuration.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def configure
|
|
19
|
+
yield config
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Test-only escape hatch: swap the whole configuration object.
|
|
23
|
+
def config=(configuration)
|
|
24
|
+
@config = configuration
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def user_klass
|
|
28
|
+
config.user_class.constantize
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def admin?(user)
|
|
32
|
+
user.present? && !!config.admin_check.call(user)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def leadership?(user)
|
|
36
|
+
user.present? && !!config.leadership_check.call(user)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Leadership members resolvable to actual user records (for bell
|
|
40
|
+
# notifications). Emails configured but absent from the user table are
|
|
41
|
+
# still reachable by email — see Notifications.
|
|
42
|
+
def leadership_users
|
|
43
|
+
emails = config.leadership_emails.map { |e| e.to_s.downcase.strip }.reject(&:empty?)
|
|
44
|
+
return user_klass.none if emails.empty?
|
|
45
|
+
|
|
46
|
+
user_klass.where("LOWER(#{user_klass.table_name}.email) IN (?)", emails)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def admin_users
|
|
50
|
+
user_klass.all.select { |u| admin?(u) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Everyone HR tracks (host-overridable to exclude bots/test accounts),
|
|
54
|
+
# sorted by display name for team screens.
|
|
55
|
+
def employees
|
|
56
|
+
config.employees_scope.call.sort_by { |u| display_name(u).downcase }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def display_name(user)
|
|
60
|
+
return "" if user.nil?
|
|
61
|
+
|
|
62
|
+
[ config.display_name_method, :display_name, :name, :email ].each do |m|
|
|
63
|
+
next unless m && user.respond_to?(m)
|
|
64
|
+
value = user.public_send(m).presence
|
|
65
|
+
return value if value
|
|
66
|
+
end
|
|
67
|
+
"User ##{user.id}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Absolute public URL for an engine-relative path, from
|
|
71
|
+
# config.public_url_base. Nil when no base is configured — callers (and
|
|
72
|
+
# emails) then simply carry no link. Hosts use this to build bell
|
|
73
|
+
# deep-links and profile links without re-deriving the HR host.
|
|
74
|
+
def public_url(path = "/")
|
|
75
|
+
base = config.public_url_base.to_s.chomp("/")
|
|
76
|
+
return nil if base.empty?
|
|
77
|
+
|
|
78
|
+
"#{base}#{path}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# True when the given absolute URL points at the configured public HR
|
|
82
|
+
# host — the allowlist check for hosts that follow stored notification
|
|
83
|
+
# links (an open-redirect guard stays intact on their side).
|
|
84
|
+
def public_url?(candidate)
|
|
85
|
+
base = public_url
|
|
86
|
+
return false if base.nil?
|
|
87
|
+
|
|
88
|
+
candidate_uri = URI.parse(candidate.to_s)
|
|
89
|
+
base_uri = URI.parse(base)
|
|
90
|
+
%w[http https].include?(candidate_uri.scheme) && candidate_uri.host == base_uri.host
|
|
91
|
+
rescue URI::InvalidURIError
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Host bell hook. Never raises — a notification must not break the
|
|
96
|
+
# domain action that triggered it.
|
|
97
|
+
def notify(user:, kind:, title:, body: nil, path: nil)
|
|
98
|
+
config.notify.call(user: user, kind: kind, title: title, body: body, path: path)
|
|
99
|
+
rescue => e
|
|
100
|
+
Rails.logger.error("[hr_lite] notify failed: #{e.class}: #{e.message}")
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
namespace :hr_lite do
|
|
2
|
+
desc "Idempotently seed HR reference data (leave types, national holidays)"
|
|
3
|
+
task seed: :environment do
|
|
4
|
+
require "hr_lite/seeds"
|
|
5
|
+
created = HrLite::Seeds.run!
|
|
6
|
+
puts created.any? ? "hr_lite:seed created: #{created.join(', ')}" : "hr_lite:seed — nothing to do"
|
|
7
|
+
end
|
|
8
|
+
end
|