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.
Files changed (176) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +33 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +166 -0
  5. data/Rakefile +3 -0
  6. data/app/assets/javascripts/hr_lite/geo_punch.js +61 -0
  7. data/app/assets/javascripts/hr_lite/mention.js +100 -0
  8. data/app/assets/stylesheets/hr_lite/application.css +15 -0
  9. data/app/assets/stylesheets/hr_lite/hr_lite.css +239 -0
  10. data/app/controllers/hr_lite/admin/appraisals_controller.rb +74 -0
  11. data/app/controllers/hr_lite/admin/attendances_controller.rb +79 -0
  12. data/app/controllers/hr_lite/admin/audit_logs_controller.rb +9 -0
  13. data/app/controllers/hr_lite/admin/base_controller.rb +16 -0
  14. data/app/controllers/hr_lite/admin/designation_changes_controller.rb +33 -0
  15. data/app/controllers/hr_lite/admin/employees_controller.rb +53 -0
  16. data/app/controllers/hr_lite/admin/holidays_controller.rb +70 -0
  17. data/app/controllers/hr_lite/admin/leadership_controller.rb +18 -0
  18. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +41 -0
  19. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +41 -0
  20. data/app/controllers/hr_lite/admin/leave_types_controller.rb +54 -0
  21. data/app/controllers/hr_lite/admin/office_locations_controller.rb +46 -0
  22. data/app/controllers/hr_lite/admin/overview_controller.rb +12 -0
  23. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +93 -0
  24. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +44 -0
  25. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +49 -0
  26. data/app/controllers/hr_lite/admin/settings_controller.rb +18 -0
  27. data/app/controllers/hr_lite/application_controller.rb +76 -0
  28. data/app/controllers/hr_lite/appraisals_controller.rb +18 -0
  29. data/app/controllers/hr_lite/attendance_controller.rb +37 -0
  30. data/app/controllers/hr_lite/calendar_controller.rb +20 -0
  31. data/app/controllers/hr_lite/career_controller.rb +9 -0
  32. data/app/controllers/hr_lite/employee_profiles_controller.rb +9 -0
  33. data/app/controllers/hr_lite/holidays_controller.rb +9 -0
  34. data/app/controllers/hr_lite/home_controller.rb +7 -0
  35. data/app/controllers/hr_lite/kudos_controller.rb +36 -0
  36. data/app/controllers/hr_lite/leave_balances_controller.rb +11 -0
  37. data/app/controllers/hr_lite/leave_requests_controller.rb +56 -0
  38. data/app/controllers/hr_lite/salary_slips_controller.rb +39 -0
  39. data/app/controllers/hr_lite/users_controller.rb +9 -0
  40. data/app/helpers/hr_lite/application_helper.rb +105 -0
  41. data/app/jobs/hr_lite/application_job.rb +4 -0
  42. data/app/jobs/hr_lite/daily_digest_job.rb +33 -0
  43. data/app/jobs/hr_lite/leave_year_rollover_job.rb +29 -0
  44. data/app/mailers/hr_lite/application_mailer.rb +5 -0
  45. data/app/mailers/hr_lite/event_mailer.rb +33 -0
  46. data/app/models/concerns/hr_lite/audited.rb +70 -0
  47. data/app/models/concerns/hr_lite/encrypted_money.rb +32 -0
  48. data/app/models/hr_lite/application_record.rb +5 -0
  49. data/app/models/hr_lite/appraisal.rb +89 -0
  50. data/app/models/hr_lite/attendance_record.rb +41 -0
  51. data/app/models/hr_lite/audit_log.rb +17 -0
  52. data/app/models/hr_lite/designation_change.rb +52 -0
  53. data/app/models/hr_lite/employee_profile.rb +72 -0
  54. data/app/models/hr_lite/holiday.rb +18 -0
  55. data/app/models/hr_lite/kudo.rb +54 -0
  56. data/app/models/hr_lite/kudo_mention.rb +8 -0
  57. data/app/models/hr_lite/leave_balance.rb +42 -0
  58. data/app/models/hr_lite/leave_request.rb +207 -0
  59. data/app/models/hr_lite/leave_type.rb +22 -0
  60. data/app/models/hr_lite/office_location.rb +21 -0
  61. data/app/models/hr_lite/payroll_run.rb +112 -0
  62. data/app/models/hr_lite/salary_slip.rb +78 -0
  63. data/app/models/hr_lite/salary_structure.rb +49 -0
  64. data/app/models/hr_lite/setting.rb +14 -0
  65. data/app/services/hr_lite/attendance_puncher.rb +103 -0
  66. data/app/services/hr_lite/attendance_summary.rb +98 -0
  67. data/app/services/hr_lite/calculators/esi.rb +25 -0
  68. data/app/services/hr_lite/calculators/pf.rb +32 -0
  69. data/app/services/hr_lite/calculators/professional_tax.rb +20 -0
  70. data/app/services/hr_lite/calculators/proration.rb +30 -0
  71. data/app/services/hr_lite/calculators/tds.rb +74 -0
  72. data/app/services/hr_lite/day_status.rb +49 -0
  73. data/app/services/hr_lite/leave_day_counter.rb +24 -0
  74. data/app/services/hr_lite/overview_query.rb +39 -0
  75. data/app/services/hr_lite/payroll_run_processor.rb +46 -0
  76. data/app/services/hr_lite/pdf_renderer.rb +23 -0
  77. data/app/services/hr_lite/slip_builder.rb +106 -0
  78. data/app/services/hr_lite/working_calendar.rb +41 -0
  79. data/app/views/hr_lite/admin/appraisals/_form.html.erb +29 -0
  80. data/app/views/hr_lite/admin/appraisals/edit.html.erb +6 -0
  81. data/app/views/hr_lite/admin/appraisals/new.html.erb +6 -0
  82. data/app/views/hr_lite/admin/attendances/index.html.erb +49 -0
  83. data/app/views/hr_lite/admin/attendances/show.html.erb +55 -0
  84. data/app/views/hr_lite/admin/audit_logs/index.html.erb +39 -0
  85. data/app/views/hr_lite/admin/designation_changes/new.html.erb +19 -0
  86. data/app/views/hr_lite/admin/employees/_form.html.erb +40 -0
  87. data/app/views/hr_lite/admin/employees/edit.html.erb +3 -0
  88. data/app/views/hr_lite/admin/employees/index.html.erb +40 -0
  89. data/app/views/hr_lite/admin/employees/new.html.erb +3 -0
  90. data/app/views/hr_lite/admin/employees/show.html.erb +75 -0
  91. data/app/views/hr_lite/admin/holidays/index.html.erb +59 -0
  92. data/app/views/hr_lite/admin/leave_balances/index.html.erb +58 -0
  93. data/app/views/hr_lite/admin/leave_requests/index.html.erb +35 -0
  94. data/app/views/hr_lite/admin/leave_requests/show.html.erb +39 -0
  95. data/app/views/hr_lite/admin/leave_types/_form.html.erb +27 -0
  96. data/app/views/hr_lite/admin/leave_types/edit.html.erb +10 -0
  97. data/app/views/hr_lite/admin/leave_types/index.html.erb +39 -0
  98. data/app/views/hr_lite/admin/leave_types/new.html.erb +3 -0
  99. data/app/views/hr_lite/admin/office_locations/_form.html.erb +18 -0
  100. data/app/views/hr_lite/admin/office_locations/edit.html.erb +10 -0
  101. data/app/views/hr_lite/admin/office_locations/index.html.erb +30 -0
  102. data/app/views/hr_lite/admin/office_locations/new.html.erb +3 -0
  103. data/app/views/hr_lite/admin/overview/index.html.erb +72 -0
  104. data/app/views/hr_lite/admin/payroll_runs/index.html.erb +33 -0
  105. data/app/views/hr_lite/admin/payroll_runs/new.html.erb +20 -0
  106. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +76 -0
  107. data/app/views/hr_lite/admin/salary_slips/show.html.erb +42 -0
  108. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +25 -0
  109. data/app/views/hr_lite/admin/salary_structures/edit.html.erb +6 -0
  110. data/app/views/hr_lite/admin/salary_structures/new.html.erb +6 -0
  111. data/app/views/hr_lite/admin/settings/edit.html.erb +18 -0
  112. data/app/views/hr_lite/appraisals/index.html.erb +22 -0
  113. data/app/views/hr_lite/appraisals/show.html.erb +32 -0
  114. data/app/views/hr_lite/attendance/_month_grid.html.erb +41 -0
  115. data/app/views/hr_lite/attendance/_punch_card.html.erb +41 -0
  116. data/app/views/hr_lite/attendance/show.html.erb +22 -0
  117. data/app/views/hr_lite/calendar/show.html.erb +45 -0
  118. data/app/views/hr_lite/career/show.html.erb +49 -0
  119. data/app/views/hr_lite/employee_profiles/show.html.erb +22 -0
  120. data/app/views/hr_lite/event_mailer/event.html.erb +10 -0
  121. data/app/views/hr_lite/event_mailer/event.text.erb +10 -0
  122. data/app/views/hr_lite/event_mailer/leadership.html.erb +27 -0
  123. data/app/views/hr_lite/event_mailer/leadership.text.erb +16 -0
  124. data/app/views/hr_lite/holidays/_next_holiday.html.erb +12 -0
  125. data/app/views/hr_lite/holidays/index.html.erb +30 -0
  126. data/app/views/hr_lite/home/index.html.erb +25 -0
  127. data/app/views/hr_lite/kudos/_kudo.html.erb +17 -0
  128. data/app/views/hr_lite/kudos/index.html.erb +39 -0
  129. data/app/views/hr_lite/leave_balances/index.html.erb +19 -0
  130. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +17 -0
  131. data/app/views/hr_lite/leave_requests/index.html.erb +37 -0
  132. data/app/views/hr_lite/leave_requests/new.html.erb +36 -0
  133. data/app/views/hr_lite/leave_requests/show.html.erb +25 -0
  134. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +48 -0
  135. data/app/views/hr_lite/salary_slips/index.html.erb +29 -0
  136. data/app/views/hr_lite/salary_slips/pdf.html.erb +72 -0
  137. data/app/views/hr_lite/salary_slips/show.html.erb +12 -0
  138. data/app/views/hr_lite/shared/_form_errors.html.erb +10 -0
  139. data/app/views/hr_lite/shared/_pagination.html.erb +11 -0
  140. data/app/views/layouts/hr_lite/application.html.erb +56 -0
  141. data/app/views/layouts/hr_lite/mailer.html.erb +30 -0
  142. data/app/views/layouts/hr_lite/mailer.text.erb +6 -0
  143. data/app/views/layouts/hr_lite/pdf.html.erb +26 -0
  144. data/config/routes.rb +57 -0
  145. data/db/migrate/20260718190726_create_hr_lite_kudos.rb +14 -0
  146. data/db/migrate/20260718190727_create_hr_lite_kudo_mentions.rb +12 -0
  147. data/db/migrate/20260718190728_create_hr_lite_audit_logs.rb +15 -0
  148. data/db/migrate/20260718192342_create_hr_lite_office_locations.rb +13 -0
  149. data/db/migrate/20260718192343_create_hr_lite_attendance_records.rb +27 -0
  150. data/db/migrate/20260718193045_create_hr_lite_holidays.rb +14 -0
  151. data/db/migrate/20260718193046_create_hr_lite_leave_types.rb +20 -0
  152. data/db/migrate/20260718193047_create_hr_lite_leave_balances.rb +17 -0
  153. data/db/migrate/20260718193048_create_hr_lite_leave_requests.rb +23 -0
  154. data/db/migrate/20260718193049_create_hr_lite_settings.rb +10 -0
  155. data/db/migrate/20260718194534_create_hr_lite_employee_profiles.rb +27 -0
  156. data/db/migrate/20260718194535_create_hr_lite_salary_structures.rb +23 -0
  157. data/db/migrate/20260718194536_create_hr_lite_payroll_runs.rb +20 -0
  158. data/db/migrate/20260718194537_create_hr_lite_salary_slips.rb +28 -0
  159. data/db/migrate/20260718195953_create_hr_lite_appraisals.rb +22 -0
  160. data/db/migrate/20260718195954_create_hr_lite_designation_changes.rb +17 -0
  161. data/lib/generators/hr_lite/install/install_generator.rb +29 -0
  162. data/lib/generators/hr_lite/install/templates/AFTER_INSTALL +35 -0
  163. data/lib/generators/hr_lite/install/templates/initializer.rb +45 -0
  164. data/lib/hr_lite/configuration.rb +60 -0
  165. data/lib/hr_lite/current.rb +7 -0
  166. data/lib/hr_lite/engine.rb +39 -0
  167. data/lib/hr_lite/geo.rb +22 -0
  168. data/lib/hr_lite/mention_parser.rb +18 -0
  169. data/lib/hr_lite/money.rb +33 -0
  170. data/lib/hr_lite/notifications.rb +99 -0
  171. data/lib/hr_lite/seeds.rb +42 -0
  172. data/lib/hr_lite/statutory_rate_card.rb +60 -0
  173. data/lib/hr_lite/version.rb +3 -0
  174. data/lib/hr_lite.rb +104 -0
  175. data/lib/tasks/hr_lite_tasks.rake +8 -0
  176. metadata +250 -0
@@ -0,0 +1,33 @@
1
+ module HrLite
2
+ # One mailer, two generic responsive templates. Per-event content (heading,
3
+ # body, detail lines, diff table) is assembled by the Notifications bus so
4
+ # adding an event never means adding a template.
5
+ class EventMailer < ApplicationMailer
6
+ def event(to:, subject:, heading:, body: nil, lines: [], path: nil)
7
+ @heading = heading
8
+ @body = body
9
+ @lines = Array(lines)
10
+ @cta_url = HrLite::EventMailer.link_for(path)
11
+ mail(to: to, from: HrLite.config.mailer_from, subject: subject)
12
+ end
13
+
14
+ def leadership(to:, subject:, heading:, body: nil, lines: [], diff: nil, path: nil, event: nil)
15
+ @heading = heading
16
+ @body = body
17
+ @lines = Array(lines)
18
+ @diff = diff.presence
19
+ @event = event
20
+ @cta_url = HrLite::EventMailer.link_for(path)
21
+ mail(to: to, from: HrLite.config.mailer_from, subject: subject)
22
+ end
23
+
24
+ # Emails need absolute URLs; the engine can't know its public mount.
25
+ # Hosts set config.public_url_base (e.g. "https://hr.example.com");
26
+ # unset => emails simply carry no link button.
27
+ def self.link_for(path)
28
+ return nil if path.blank?
29
+
30
+ HrLite.public_url(path)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,70 @@
1
+ module HrLite
2
+ # Include in every leadership-mutable model. Each create/update/destroy
3
+ # writes an append-only AuditLog row and publishes policy.changed to
4
+ # leadership. Encrypted attributes are logged as "[changed]" — plaintext
5
+ # PII never reaches the audit table or email.
6
+ module Audited
7
+ extend ActiveSupport::Concern
8
+
9
+ SKIPPED_ATTRIBUTES = %w[updated_at created_at].freeze
10
+ REDACTED = "[changed]".freeze
11
+
12
+ included do
13
+ after_create { hr_lite_audit!("create") }
14
+ after_update { hr_lite_audit!("update") }
15
+ after_destroy { hr_lite_audit!("destroy") }
16
+ end
17
+
18
+ private
19
+
20
+ def hr_lite_audit!(action)
21
+ changes = hr_lite_audited_changes(action)
22
+ return if action == "update" && changes.empty?
23
+
24
+ log = HrLite::AuditLog.create!(
25
+ actor: HrLite::Current.actor,
26
+ action: action,
27
+ subject_type: self.class.name,
28
+ subject_id: id,
29
+ audited_changes: changes
30
+ )
31
+ hr_lite_publish_policy_change(log)
32
+ rescue => e
33
+ # The trail is best-effort by design: an audit hiccup must never roll
34
+ # back the domain write it describes. Logged loudly instead.
35
+ Rails.logger.error("[hr_lite] audit failed: #{e.class}: #{e.message}")
36
+ end
37
+
38
+ def hr_lite_audited_changes(action)
39
+ encrypted = self.class.try(:encrypted_attributes)&.map(&:to_s) || []
40
+
41
+ case action
42
+ when "destroy"
43
+ { "_destroyed" => hr_lite_audit_label }
44
+ else
45
+ saved_changes.except(*SKIPPED_ATTRIBUTES).to_h do |attr, (from, to)|
46
+ if encrypted.include?(attr)
47
+ [ attr, REDACTED ]
48
+ else
49
+ [ attr, [ from, to ] ]
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ def hr_lite_audit_label
56
+ try(:name) || try(:title) || "#{self.class.name.demodulize} ##{id}"
57
+ end
58
+
59
+ def hr_lite_publish_policy_change(log)
60
+ actor_name = HrLite.display_name(HrLite::Current.actor)
61
+ subject = "#{self.class.name.demodulize.underscore.humanize} — #{hr_lite_audit_label}"
62
+ HrLite::Notifications.publish(
63
+ "policy.changed",
64
+ title: "#{actor_name.presence || 'Someone'} #{log.action}d #{subject}",
65
+ body: nil,
66
+ diff: log.audited_changes
67
+ )
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,32 @@
1
+ module HrLite
2
+ # Money that must be encrypted at rest. Rails `encrypts` only works on
3
+ # text columns, so amounts are stored as canonical decimal strings and
4
+ # surfaced as BigDecimal. BigDecimal in, BigDecimal out; Float never.
5
+ #
6
+ # Accepted trade-off (documented): encrypted amounts cannot be aggregated
7
+ # in SQL — all totals are computed in Ruby. Fine well past 100 employees.
8
+ module EncryptedMoney
9
+ extend ActiveSupport::Concern
10
+
11
+ class_methods do
12
+ def encrypted_money(*attributes)
13
+ attributes.each do |attribute|
14
+ encrypts attribute
15
+
16
+ define_method(attribute) do
17
+ raw = super()
18
+ raw.nil? ? nil : BigDecimal(raw)
19
+ end
20
+
21
+ define_method("#{attribute}=") do |value|
22
+ if value.nil? || (value.respond_to?(:blank?) && value.blank?)
23
+ super(nil)
24
+ else
25
+ super(BigDecimal(value.to_s).round(2).to_s("F"))
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ module HrLite
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,89 @@
1
+ module HrLite
2
+ # Individual performance review. Drafted and edited by leadership,
3
+ # invisible to the employee until shared; sharing locks it permanently —
4
+ # a shared review is a record, corrections go in the next appraisal.
5
+ # A promotion outcome records a DesignationChange at share time.
6
+ class Appraisal < ApplicationRecord
7
+ include Audited
8
+
9
+ OUTCOMES = %w[none increment promotion].freeze
10
+ STATUSES = %w[draft shared].freeze
11
+
12
+ belongs_to :user, class_name: HrLite.config.user_class
13
+ belongs_to :reviewer, class_name: HrLite.config.user_class
14
+ has_one :designation_change, dependent: :nullify
15
+
16
+ validates :period_start, :period_end, presence: true
17
+ validates :rating, inclusion: { in: 1..5 }, allow_nil: true
18
+ validates :outcome, inclusion: { in: OUTCOMES }
19
+ validates :status, inclusion: { in: STATUSES }
20
+ validates :effective_date, presence: true, unless: -> { outcome == "none" }
21
+ validates :new_designation, presence: true, if: -> { outcome == "promotion" }
22
+ validate :period_order
23
+ validate :locked_after_share
24
+ before_destroy :draft_only_destroy
25
+
26
+ scope :shared, -> { where(status: "shared") }
27
+ scope :recent_first, -> { order(period_end: :desc, id: :desc) }
28
+
29
+ def draft? = status == "draft"
30
+ def shared? = status == "shared"
31
+
32
+ def share!(actor:)
33
+ raise ActiveRecord::RecordInvalid.new(self), "already shared" unless draft?
34
+
35
+ transaction do
36
+ update!(status: "shared", shared_at: Time.current)
37
+ record_promotion!(actor) if outcome == "promotion"
38
+ end
39
+
40
+ Notifications.publish(
41
+ "appraisal.shared",
42
+ title: "Your appraisal for #{period_label} has been shared",
43
+ body: rating ? "Rating: #{rating}/5" : nil,
44
+ path: "/appraisals/#{id}",
45
+ bell_to: [ user ],
46
+ email_to: [ user ]
47
+ )
48
+ true
49
+ end
50
+
51
+ def period_label
52
+ "#{period_start.strftime('%b %Y')} – #{period_end.strftime('%b %Y')}"
53
+ end
54
+
55
+ private
56
+
57
+ def record_promotion!(actor)
58
+ DesignationChange.create!(
59
+ user_id: user_id,
60
+ to_designation: new_designation,
61
+ effective_date: effective_date,
62
+ note: "Promotion via appraisal #{period_label}",
63
+ appraisal_id: id,
64
+ created_by_id: actor.id
65
+ )
66
+ end
67
+
68
+ def draft_only_destroy
69
+ return if draft?
70
+
71
+ errors.add(:base, "Shared appraisals are permanent records")
72
+ throw :abort
73
+ end
74
+
75
+ def period_order
76
+ return unless period_start && period_end
77
+
78
+ errors.add(:period_end, "must be after the start") if period_end < period_start
79
+ end
80
+
81
+ def locked_after_share
82
+ return if new_record? || draft?
83
+ # The share! transition itself sets status/shared_at; any other change
84
+ # to a shared appraisal is tampering.
85
+ changed_keys = changes.keys - %w[status shared_at updated_at]
86
+ errors.add(:base, "A shared appraisal cannot be edited") if changed_keys.any? && status_was == "shared"
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,41 @@
1
+ module HrLite
2
+ class AttendanceRecord < ApplicationRecord
3
+ STATUSES = %w[present half_day].freeze
4
+
5
+ belongs_to :user, class_name: HrLite.config.user_class
6
+ belongs_to :regularized_by, class_name: HrLite.config.user_class, optional: true
7
+
8
+ validates :date, presence: true, uniqueness: { scope: :user_id }
9
+ validates :status, inclusion: { in: STATUSES }
10
+ validate :check_out_after_check_in
11
+
12
+ scope :for_date, ->(date) { where(date: date) }
13
+ scope :for_month, ->(month) { where(date: month.beginning_of_month..month.end_of_month) }
14
+ scope :flagged, -> { where(flagged: true) }
15
+ scope :missing_checkout, -> { where.not(check_in_at: nil).where(check_out_at: nil) }
16
+
17
+ def regularized?
18
+ regularized_at.present?
19
+ end
20
+
21
+ def worked_duration
22
+ return nil unless check_in_at && check_out_at
23
+
24
+ check_out_at - check_in_at
25
+ end
26
+
27
+ def add_flag!(note)
28
+ self.flagged = true
29
+ self.flag_note = [ flag_note.presence, note ].compact.join("; ")
30
+ end
31
+
32
+ private
33
+
34
+ def check_out_after_check_in
35
+ return unless check_in_at && check_out_at
36
+ return if check_out_at >= check_in_at
37
+
38
+ errors.add(:check_out_at, "must be after check-in")
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,17 @@
1
+ module HrLite
2
+ # Append-only trail of every governing-tier mutation. Rows are never
3
+ # updated or deleted; the leadership audit screen and the policy.changed
4
+ # email diff both read from here.
5
+ class AuditLog < ApplicationRecord
6
+ belongs_to :actor, class_name: HrLite.config.user_class, optional: true
7
+ belongs_to :subject, polymorphic: true, optional: true
8
+
9
+ validates :action, :subject_type, :subject_id, presence: true
10
+
11
+ scope :recent, -> { order(created_at: :desc) }
12
+
13
+ def readonly?
14
+ persisted?
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,52 @@
1
+ module HrLite
2
+ # One promotion / role change. Creating it stamps the employee profile's
3
+ # current designation and tells the host (so e.g. the CMS job title stays
4
+ # in sync). Rows are the career timeline — never edited or deleted.
5
+ class DesignationChange < ApplicationRecord
6
+ include Audited
7
+
8
+ belongs_to :user, class_name: HrLite.config.user_class
9
+ belongs_to :appraisal, optional: true
10
+ belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
11
+
12
+ validates :to_designation, :effective_date, presence: true
13
+
14
+ scope :timeline, -> { order(effective_date: :desc, id: :desc) }
15
+
16
+ before_create :capture_from_designation
17
+ after_create :apply_to_profile
18
+ after_create :notify
19
+
20
+ def readonly?
21
+ persisted? && !destroyed?
22
+ end
23
+
24
+ private
25
+
26
+ def capture_from_designation
27
+ self.from_designation ||= EmployeeProfile.find_by(user_id: user_id)&.designation
28
+ end
29
+
30
+ def apply_to_profile
31
+ profile = EmployeeProfile.find_by(user_id: user_id)
32
+ profile&.update!(designation: to_designation)
33
+
34
+ begin
35
+ HrLite.config.on_designation_change.call(user, to_designation)
36
+ rescue => e
37
+ Rails.logger.error("[hr_lite] on_designation_change failed: #{e.class}: #{e.message}")
38
+ end
39
+ end
40
+
41
+ def notify
42
+ Notifications.publish(
43
+ "promotion.recorded",
44
+ title: "New role: #{to_designation}",
45
+ body: "Effective #{effective_date.strftime('%d %b %Y')}.",
46
+ path: "/career",
47
+ bell_to: [ user ],
48
+ email_to: [ user ]
49
+ )
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,72 @@
1
+ module HrLite
2
+ # Statutory identity + employment window. PII columns are encrypted at
3
+ # rest; masked readers feed the employee-facing view (full values render
4
+ # only in the leadership edit form). Records are never destroyed —
5
+ # exits are a date, payroll history is a statutory record.
6
+ class EmployeeProfile < ApplicationRecord
7
+ include EncryptedMoney
8
+ include Audited
9
+
10
+ belongs_to :user, class_name: HrLite.config.user_class
11
+
12
+ encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc
13
+ encrypted_money :declared_annual_deductions
14
+
15
+ TAX_REGIMES = %w[new old].freeze
16
+
17
+ validates :employee_code, presence: true, uniqueness: true
18
+ validates :user_id, uniqueness: true
19
+ validates :date_of_joining, presence: true
20
+ validates :tax_regime, inclusion: { in: TAX_REGIMES }
21
+ validates :pan_number, format: { with: /\A[A-Z]{5}[0-9]{4}[A-Z]\z/, message: "is not a valid PAN" },
22
+ allow_blank: true
23
+ validates :bank_ifsc, format: { with: /\A[A-Z]{4}0[A-Z0-9]{6}\z/, message: "is not a valid IFSC" },
24
+ allow_blank: true
25
+ validates :pf_uan, format: { with: /\A\d{12}\z/, message: "must be 12 digits" }, allow_blank: true
26
+ validate :exit_after_joining
27
+
28
+ scope :active_for, ->(month) {
29
+ where(date_of_joining: ..month.end_of_month)
30
+ .where("date_of_exit IS NULL OR date_of_exit >= ?", month.beginning_of_month)
31
+ }
32
+
33
+ def active_on?(date)
34
+ date_of_joining <= date && (date_of_exit.nil? || date_of_exit >= date)
35
+ end
36
+
37
+ def employment_range_in(month)
38
+ from = [ date_of_joining, month.beginning_of_month ].max
39
+ to = [ date_of_exit || month.end_of_month, month.end_of_month ].min
40
+ from <= to ? (from..to) : nil
41
+ end
42
+
43
+ def masked_pan
44
+ mask_middle(pan_number)
45
+ end
46
+
47
+ def masked_account
48
+ value = bank_account_number
49
+ value.blank? ? nil : "•••• #{value.last(4)}"
50
+ end
51
+
52
+ def masked_uan
53
+ mask_middle(pf_uan)
54
+ end
55
+
56
+ private
57
+
58
+ def mask_middle(value)
59
+ return nil if value.blank?
60
+ return "•" * value.length if value.length <= 4
61
+
62
+ "#{value.first(2)}#{'•' * (value.length - 4)}#{value.last(2)}"
63
+ end
64
+
65
+ def exit_after_joining
66
+ return unless date_of_exit && date_of_joining
67
+ return if date_of_exit >= date_of_joining
68
+
69
+ errors.add(:date_of_exit, "must be after joining")
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,18 @@
1
+ module HrLite
2
+ class Holiday < ApplicationRecord
3
+ include Audited
4
+
5
+ validates :date, presence: true, uniqueness: true
6
+ validates :name, presence: true
7
+
8
+ scope :company_wide, -> { where(optional: false) }
9
+ scope :in_range, ->(range) { where(date: range) }
10
+ scope :upcoming, -> { where(date: Date.current..).order(:date) }
11
+
12
+ # Company-wide holiday dates only — optional holidays never exclude
13
+ # working days.
14
+ def self.dates_for(range)
15
+ company_wide.in_range(range).pluck(:date).to_set
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,54 @@
1
+ module HrLite
2
+ class Kudo < ApplicationRecord
3
+ belongs_to :giver, class_name: HrLite.config.user_class
4
+ has_many :kudo_mentions, dependent: :destroy
5
+ has_many :mentioned_users, through: :kudo_mentions, source: :user
6
+
7
+ # Text-labelled badges (rendered with an inline SVG icon, never emoji).
8
+ BADGES = {
9
+ "team_player" => "Team player",
10
+ "above_and_beyond" => "Going above and beyond",
11
+ "customer_hero" => "Customer hero",
12
+ "problem_solver" => "Problem solver",
13
+ "culture_champion" => "Culture champion"
14
+ }.freeze
15
+
16
+ DELETE_WINDOW = 15.minutes
17
+
18
+ validates :message, presence: true, length: { maximum: 1000 }
19
+ validates :badge, inclusion: { in: BADGES.keys }, allow_blank: true
20
+
21
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
22
+
23
+ # Praise is immutable; the giver gets a short window to undo a mistake,
24
+ # admins/leadership can moderate any time.
25
+ def deletable_by?(user)
26
+ return false if user.nil?
27
+ return true if HrLite.admin?(user) || HrLite.leadership?(user)
28
+
29
+ giver_id == user.id && created_at > DELETE_WINDOW.ago
30
+ end
31
+
32
+ def badge_label
33
+ BADGES[badge]
34
+ end
35
+
36
+ # Creates mention rows from the message markers (existing users only,
37
+ # giver excluded) and notifies them. Called once, right after create.
38
+ def register_mentions!
39
+ ids = HrLite::MentionParser.user_ids(message) - [ giver_id ]
40
+ users = HrLite.user_klass.where(id: ids)
41
+ users.each { |u| kudo_mentions.create!(user_id: u.id) }
42
+
43
+ HrLite::Notifications.publish(
44
+ "kudos.mentioned",
45
+ title: "#{HrLite.display_name(giver)} gave you kudos",
46
+ body: HrLite::MentionParser.strip_markers(message).truncate(140),
47
+ path: "/kudos",
48
+ bell_to: users.to_a,
49
+ email_to: users.to_a
50
+ )
51
+ users
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,8 @@
1
+ module HrLite
2
+ class KudoMention < ApplicationRecord
3
+ belongs_to :kudo
4
+ belongs_to :user, class_name: HrLite.config.user_class
5
+
6
+ validates :user_id, uniqueness: { scope: :kudo_id }
7
+ end
8
+ end
@@ -0,0 +1,42 @@
1
+ module HrLite
2
+ # Hybrid balance: only carry-in and manual adjustments are stored;
3
+ # entitlement accrues as a pure function of the policy and `used` is
4
+ # recomputed live from approved requests — so a holiday added after an
5
+ # approval self-heals both the quota and payroll.
6
+ class LeaveBalance < ApplicationRecord
7
+ belongs_to :user, class_name: HrLite.config.user_class
8
+ belongs_to :leave_type
9
+
10
+ validates :year, presence: true,
11
+ uniqueness: { scope: %i[user_id leave_type_id] }
12
+
13
+ def self.for(user, leave_type, year)
14
+ find_or_initialize_by(user_id: user.id, leave_type: leave_type, year: year)
15
+ end
16
+
17
+ def entitled(as_of: Date.current)
18
+ return Float::INFINITY if leave_type.unlimited?
19
+
20
+ quota = leave_type.annual_quota
21
+ base =
22
+ if leave_type.accrual == "monthly"
23
+ months = as_of.year == year ? as_of.month : (as_of.year > year ? 12 : 0)
24
+ ((quota / 12) * months).round(1)
25
+ else
26
+ quota
27
+ end
28
+ base + carried_forward + adjustment
29
+ end
30
+
31
+ def used
32
+ requests = LeaveRequest.approved
33
+ .where(user_id: user_id, leave_type_id: leave_type_id)
34
+ .where(start_date: Date.new(year, 1, 1)..Date.new(year, 12, 31))
35
+ requests.sum { |request| LeaveDayCounter.count(request) }
36
+ end
37
+
38
+ def available(as_of: Date.current)
39
+ entitled(as_of: as_of) - used
40
+ end
41
+ end
42
+ end