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,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,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,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
|