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,44 @@
1
+ module HrLite
2
+ module Admin
3
+ class SalarySlipsController < LeadershipController
4
+ def show
5
+ @slip = SalarySlip.includes(:payroll_run).find(params[:id])
6
+ @profile = @slip.user_profile
7
+ end
8
+
9
+ # Review-stage overrides (LOP days / monthly TDS) — the slip rebuilds
10
+ # immediately with the overrides applied.
11
+ def update
12
+ slip = SalarySlip.includes(:payroll_run).find(params[:id])
13
+ run = slip.payroll_run
14
+ unless run.review?
15
+ return redirect_to admin_salary_slip_path(slip),
16
+ alert: "Overrides are only editable while the run is in review."
17
+ end
18
+
19
+ lop_override = params.dig(:salary_slip, :lop_override).presence
20
+ tds_override = params.dig(:salary_slip, :tds_override).presence
21
+ profile = EmployeeProfile.find_by(user_id: slip.user_id)
22
+ structure = SalaryStructure.effective_for(slip.user, run.period_month)
23
+
24
+ attributes = SlipBuilder.call(
25
+ run: run, user: slip.user, structure: structure, profile: profile,
26
+ lop_override: lop_override && BigDecimal(lop_override),
27
+ tds_override: tds_override && BigDecimal(tds_override)
28
+ )
29
+ slip.update!(attributes)
30
+
31
+ AuditLog.create!(
32
+ actor: hr_current_user, action: "override",
33
+ subject_type: slip.class.name, subject_id: slip.id,
34
+ audited_changes: { "period" => run.label, "employee" => HrLite.display_name(slip.user),
35
+ "lop_override" => lop_override || "cleared",
36
+ "tds_override" => tds_override ? "[changed]" : "cleared" }
37
+ )
38
+ redirect_to admin_salary_slip_path(slip), notice: "Slip recomputed with overrides."
39
+ rescue ArgumentError
40
+ redirect_to admin_salary_slip_path(params[:id]), alert: "Overrides must be numbers."
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,49 @@
1
+ module HrLite
2
+ module Admin
3
+ class SalaryStructuresController < LeadershipController
4
+ before_action :set_profile
5
+
6
+ def new
7
+ @structure = SalaryStructure.new(user_id: @profile.user_id,
8
+ effective_from: Date.current.beginning_of_month)
9
+ end
10
+
11
+ def create
12
+ @structure = SalaryStructure.new(structure_params.merge(
13
+ user_id: @profile.user_id, created_by_id: hr_current_user.id
14
+ ))
15
+ if @structure.save
16
+ redirect_to admin_employee_path(@profile), notice: "Salary structure saved."
17
+ else
18
+ render :new, status: :unprocessable_entity
19
+ end
20
+ end
21
+
22
+ def edit
23
+ @structure = SalaryStructure.where(user_id: @profile.user_id).find(params[:id])
24
+ end
25
+
26
+ def update
27
+ @structure = SalaryStructure.where(user_id: @profile.user_id).find(params[:id])
28
+ if @structure.update(structure_params)
29
+ redirect_to admin_employee_path(@profile), notice: "Salary structure updated."
30
+ else
31
+ render :edit, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def set_profile
38
+ @profile = EmployeeProfile.find(params[:employee_id])
39
+ end
40
+
41
+ def structure_params
42
+ params.require(:salary_structure).permit(
43
+ :effective_from, :basic, :hra, :special_allowance, :other_earnings,
44
+ :pf_applicable, :pf_on_full_basic, :esi_applicable, :pt_state, :notes
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,18 @@
1
+ module HrLite
2
+ module Admin
3
+ class SettingsController < LeadershipController
4
+ def edit
5
+ @setting = Setting.instance
6
+ end
7
+
8
+ def update
9
+ @setting = Setting.instance
10
+ if @setting.update(params.require(:setting).permit(:weekend_policy))
11
+ redirect_to edit_admin_setting_path, notice: "Settings saved."
12
+ else
13
+ render :edit, status: :unprocessable_entity
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,76 @@
1
+ module HrLite
2
+ # Inherits from the host-configured parent controller (set
3
+ # config.parent_controller in an initializer BEFORE boot; changing it needs
4
+ # a restart). Everything else — auth method, current user, admin/leadership
5
+ # checks — is resolved per request from the live config.
6
+ class ApplicationController < HrLite.config.parent_controller.constantize
7
+ layout "hr_lite/application"
8
+
9
+ before_action :hr_authenticate!
10
+ before_action :hr_set_current_actor
11
+ around_action :hr_use_time_zone
12
+
13
+ helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_display_name
14
+
15
+ private
16
+
17
+ def hr_authenticate!
18
+ send(HrLite.config.authenticate_method)
19
+ end
20
+
21
+ def hr_current_user
22
+ send(HrLite.config.current_user_method)
23
+ end
24
+
25
+ def hr_admin?
26
+ HrLite.admin?(hr_current_user)
27
+ end
28
+
29
+ def hr_leadership?
30
+ HrLite.leadership?(hr_current_user)
31
+ end
32
+
33
+ def hr_display_name(user)
34
+ HrLite.display_name(user)
35
+ end
36
+
37
+ def hr_set_current_actor
38
+ HrLite::Current.actor = hr_current_user
39
+ end
40
+
41
+ def hr_use_time_zone(&)
42
+ Time.use_zone(HrLite.config.time_zone, &)
43
+ end
44
+
45
+ def hr_access_denied
46
+ respond_to do |format|
47
+ format.html { redirect_to hr_lite.root_path, alert: "You do not have access to that area." }
48
+ format.any { head :forbidden }
49
+ end
50
+ end
51
+
52
+ # Strict param parsing: anything that isn't the exact expected format
53
+ # falls back to today (never 500s on a mangled URL).
54
+ def parse_month_param(value)
55
+ Date.strptime(value.to_s, "%Y-%m")
56
+ rescue ArgumentError, TypeError
57
+ Date.current.beginning_of_month
58
+ end
59
+
60
+ def parse_date_param(value)
61
+ Date.strptime(value.to_s, "%Y-%m-%d")
62
+ rescue ArgumentError, TypeError
63
+ Date.current
64
+ end
65
+
66
+ # Minimal LIMIT/OFFSET pagination — no host dependency.
67
+ def paginate(scope, per: 25)
68
+ @per = [ [ params.fetch(:per, per).to_i, 1 ].max, 100 ].min
69
+ @page = [ params.fetch(:page, 1).to_i, 1 ].max
70
+ @total_count = scope.count
71
+ @total_pages = [ (@total_count.to_f / @per).ceil, 1 ].max
72
+ @page = @total_pages if @page > @total_pages
73
+ scope.offset((@page - 1) * @per).limit(@per)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,18 @@
1
+ module HrLite
2
+ # Employee view: shared appraisals only — drafts do not exist for them.
3
+ class AppraisalsController < ApplicationController
4
+ def index
5
+ @appraisals = paginate(own_shared.recent_first)
6
+ end
7
+
8
+ def show
9
+ @appraisal = own_shared.find(params[:id])
10
+ end
11
+
12
+ private
13
+
14
+ def own_shared
15
+ Appraisal.shared.where(user_id: hr_current_user.id)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,37 @@
1
+ module HrLite
2
+ # Employee self-service punch surface. Always operates on hr_current_user —
3
+ # there is no user param, so punching for someone else is structurally
4
+ # impossible.
5
+ class AttendanceController < ApplicationController
6
+ def show
7
+ @month = parse_month_param(params[:month])
8
+ @record = AttendanceRecord.find_by(user_id: hr_current_user.id, date: Date.current)
9
+ @day_status = DayStatus.new(user: hr_current_user, range: @month.beginning_of_month..@month.end_of_month)
10
+ @counts = @day_status.counts
11
+ end
12
+
13
+ def check_in
14
+ punch(:check_in)
15
+ end
16
+
17
+ def check_out
18
+ punch(:check_out)
19
+ end
20
+
21
+ private
22
+
23
+ def punch(kind)
24
+ result = AttendancePuncher.call(user: hr_current_user, kind: kind, **punch_params.to_h.symbolize_keys)
25
+ if result.ok?
26
+ time = Time.current.strftime("%H:%M")
27
+ redirect_to attendance_path, notice: "#{kind == :check_in ? 'Checked in' : 'Checked out'} at #{time}."
28
+ else
29
+ redirect_to attendance_path, alert: result.error
30
+ end
31
+ end
32
+
33
+ def punch_params
34
+ params.permit(:lat, :lng, :accuracy_m, :geo_status)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,20 @@
1
+ module HrLite
2
+ # Server-rendered company calendar: holidays, everyone's approved leaves
3
+ # and weekend shading for a month. No JS dependency — works everywhere
4
+ # the engine is mounted.
5
+ class CalendarController < ApplicationController
6
+ def show
7
+ @month = parse_month_param(params[:month])
8
+ range = @month.beginning_of_month..@month.end_of_month
9
+ @calendar = WorkingCalendar.new(range)
10
+ @holidays = Holiday.in_range(range).order(:date).group_by(&:date)
11
+ @leaves = LeaveRequest.approved.includes(:leave_type, :user)
12
+ .overlapping_range(range.first, range.last)
13
+ .each_with_object(Hash.new { |h, k| h[k] = [] }) do |request, acc|
14
+ (request.start_date..request.end_date).each do |date|
15
+ acc[date] << request if range.cover?(date)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,9 @@
1
+ module HrLite
2
+ class CareerController < ApplicationController
3
+ def show
4
+ @profile = EmployeeProfile.find_by(user_id: hr_current_user.id)
5
+ @changes = DesignationChange.where(user_id: hr_current_user.id).timeline
6
+ @appraisals = Appraisal.shared.where(user_id: hr_current_user.id).recent_first.limit(10)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module HrLite
2
+ # Read-only masked view of one's own HR profile; edits go through
3
+ # leadership.
4
+ class EmployeeProfilesController < ApplicationController
5
+ def show
6
+ @profile = EmployeeProfile.find_by(user_id: hr_current_user.id)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module HrLite
2
+ class HolidaysController < ApplicationController
3
+ def index
4
+ year = params[:year].to_i
5
+ @year = year.between?(2000, 2100) ? year : Date.current.year
6
+ @holidays = Holiday.where(date: Date.new(@year, 1, 1)..Date.new(@year, 12, 31)).order(:date)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ module HrLite
2
+ class HomeController < ApplicationController
3
+ def index
4
+ @latest_kudos = Kudo.recent.includes(:giver, kudo_mentions: :user).limit(3)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,36 @@
1
+ module HrLite
2
+ class KudosController < ApplicationController
3
+ def index
4
+ @kudos = paginate(Kudo.recent.includes(:giver, kudo_mentions: :user))
5
+ @kudo = Kudo.new
6
+ end
7
+
8
+ def create
9
+ @kudo = Kudo.new(kudo_params.merge(giver: hr_current_user))
10
+ if @kudo.save
11
+ @kudo.register_mentions!
12
+ redirect_to kudos_path, notice: "Kudos posted."
13
+ else
14
+ @kudos = paginate(Kudo.recent.includes(:giver, kudo_mentions: :user))
15
+ render :index, status: :unprocessable_entity
16
+ end
17
+ end
18
+
19
+ def destroy
20
+ kudo = Kudo.find(params[:id])
21
+ if kudo.deletable_by?(hr_current_user)
22
+ kudo.destroy!
23
+ redirect_to kudos_path, notice: "Kudos removed.", status: :see_other
24
+ else
25
+ redirect_to kudos_path, alert: "You can only remove your own kudos within 15 minutes.",
26
+ status: :see_other
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def kudo_params
33
+ params.require(:kudo).permit(:message, :badge)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,11 @@
1
+ module HrLite
2
+ class LeaveBalancesController < ApplicationController
3
+ def index
4
+ year = params[:year].to_i
5
+ @year = year.between?(2000, 2100) ? year : Date.current.year
6
+ @balances = LeaveType.active.where(paid: true).where.not(annual_quota: nil).map do |type|
7
+ LeaveBalance.for(hr_current_user, type, @year)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,56 @@
1
+ module HrLite
2
+ # Employee self-service: always scoped to hr_current_user — a foreign id
3
+ # 404s, never 403s.
4
+ class LeaveRequestsController < ApplicationController
5
+ def index
6
+ @requests = paginate(own_requests.recent_first.includes(:leave_type))
7
+ @balances = balance_cards
8
+ end
9
+
10
+ def show
11
+ @request = own_requests.find(params[:id])
12
+ end
13
+
14
+ def new
15
+ @request = LeaveRequest.new(start_date: Date.current, end_date: Date.current)
16
+ @balances = balance_cards
17
+ end
18
+
19
+ def create
20
+ @request = LeaveRequest.new(request_params.merge(user_id: hr_current_user.id))
21
+ if @request.save
22
+ redirect_to leave_requests_path, notice: "Leave request submitted."
23
+ else
24
+ @balances = balance_cards
25
+ render :new, status: :unprocessable_entity
26
+ end
27
+ end
28
+
29
+ def cancel
30
+ request = own_requests.find(params[:id])
31
+ if request.cancellable_by?(hr_current_user)
32
+ request.cancel!(actor: hr_current_user)
33
+ redirect_to leave_requests_path, notice: "Leave cancelled."
34
+ else
35
+ redirect_to leave_requests_path, alert: "This request can no longer be cancelled."
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def own_requests
42
+ LeaveRequest.where(user_id: hr_current_user.id)
43
+ end
44
+
45
+ def balance_cards
46
+ year = Date.current.year
47
+ LeaveType.active.where(paid: true).where.not(annual_quota: nil).map do |type|
48
+ LeaveBalance.for(hr_current_user, type, year)
49
+ end
50
+ end
51
+
52
+ def request_params
53
+ params.require(:leave_request).permit(:leave_type_id, :start_date, :end_date, :half_day, :reason)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,39 @@
1
+ module HrLite
2
+ # Employee surface: strictly own PUBLISHED slips — a foreign or
3
+ # unpublished id 404s through the scoped relation, never 403s.
4
+ class SalarySlipsController < ApplicationController
5
+ def index
6
+ @slips = paginate(own_published.recent_first)
7
+ end
8
+
9
+ def show
10
+ @slip = own_published.find(params[:id])
11
+
12
+ respond_to do |format|
13
+ format.html
14
+ format.pdf { send_slip_pdf(@slip) }
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def own_published
21
+ SalarySlip.published.where(user_id: hr_current_user.id)
22
+ end
23
+
24
+ def send_slip_pdf(slip)
25
+ pdf = PdfRenderer.render(
26
+ template: "hr_lite/salary_slips/pdf",
27
+ assigns: { slip: slip, profile: slip.user_profile },
28
+ cache_key: slip.pdf_cache_key
29
+ )
30
+ if pdf
31
+ send_data pdf, filename: "salary-slip-#{slip.period_month.strftime('%Y-%m')}.pdf",
32
+ type: "application/pdf", disposition: "inline"
33
+ else
34
+ redirect_to salary_slip_path(slip),
35
+ alert: "PDF downloads are not configured on this install."
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,9 @@
1
+ module HrLite
2
+ # Mention autocomplete source. Available to any signed-in staff member.
3
+ class UsersController < ApplicationController
4
+ def search
5
+ users = HrLite.config.mentionable_users.call(params[:q].to_s)
6
+ render json: users.map { |u| { value: u.id, text: HrLite.display_name(u) } }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,105 @@
1
+ module HrLite
2
+ module ApplicationHelper
3
+ NAV_ITEMS = [
4
+ { label: "Home", path: :root_path, match: [ "/" ] },
5
+ { label: "Attendance", path: :attendance_path, match: [ "/attendance" ] },
6
+ { label: "Leaves", path: :leave_requests_path, match: [ "/leave_requests", "/leave_balances" ] },
7
+ { label: "Calendar", path: :calendar_path, match: [ "/calendar", "/holidays" ] },
8
+ { label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
9
+ { label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
10
+ { label: "Career", path: :career_path, match: [ "/career", "/appraisals", "/profile" ] }
11
+ ].freeze
12
+
13
+ ADMIN_NAV_ITEMS = [
14
+ { label: "Overview", path: :admin_overview_path, match: [ "/admin/overview" ] },
15
+ { label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
16
+ { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances" ] }
17
+ ].freeze
18
+
19
+ LEADERSHIP_NAV_ITEMS = [
20
+ { label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
21
+ { label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] },
22
+ { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting" ] },
23
+ { label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] }
24
+ ].freeze
25
+
26
+ # Only items whose routes exist yet (the nav grows with each phase).
27
+ def hrl_nav_items(items)
28
+ items.select { |item| hr_lite_route?(item[:path]) }
29
+ end
30
+
31
+ def hr_lite_route?(helper_name)
32
+ hr_lite.respond_to?(helper_name)
33
+ end
34
+
35
+ def hrl_nav_link(item)
36
+ href = hr_lite.public_send(item[:path])
37
+ active = item[:match].any? do |prefix|
38
+ prefix == "/" ? request.path == href : request.path.start_with?("#{hr_lite.root_path.chomp('/')}#{prefix}")
39
+ end
40
+ link_to item[:label], href, class: "hrl-nav__link #{'hrl-nav__link--active' if active}"
41
+ end
42
+
43
+ # Mention-marker-aware, XSS-safe kudos message rendering. Every literal
44
+ # chunk is escaped; only markers whose id has a mention row become
45
+ # highlighted chips (with the CURRENT display name — stale-name safe).
46
+ def render_kudo_message(kudo)
47
+ mentioned = kudo.kudo_mentions.index_by(&:user_id)
48
+ out = +""
49
+ rest = kudo.message.to_s
50
+ while (m = HrLite::MentionParser::MARKER.match(rest))
51
+ out << ERB::Util.html_escape(m.pre_match)
52
+ mention = mentioned[m[2].to_i]
53
+ if mention
54
+ out << %(<span class="hrl-mention">@#{ERB::Util.html_escape(HrLite.display_name(mention.user))}</span>)
55
+ else
56
+ out << ERB::Util.html_escape("@#{m[1]}")
57
+ end
58
+ rest = m.post_match
59
+ end
60
+ out << ERB::Util.html_escape(rest)
61
+ out.html_safe
62
+ end
63
+
64
+ def hrl_pagination
65
+ render "hr_lite/shared/pagination"
66
+ end
67
+
68
+ def hrl_money(amount)
69
+ return "—" if amount.nil?
70
+
71
+ whole, fraction = HrLite::Money.round2(amount).to_s("F").split(".")
72
+ sign = whole.delete_prefix!("-") ? "-" : ""
73
+ # Indian digit grouping: 12,34,567
74
+ grouped = whole.reverse.scan(/\d{1,2}(?=\d{2})|\d{1,3}/).join(",").reverse if whole.length > 3
75
+ grouped ||= whole
76
+ "#{sign}#{HrLite.config.currency_symbol}#{grouped}.#{fraction.ljust(2, '0')}"
77
+ end
78
+
79
+ ONES = %w[zero one two three four five six seven eight nine ten eleven twelve thirteen fourteen
80
+ fifteen sixteen seventeen eighteen nineteen].freeze
81
+ TENS = %w[zero ten twenty thirty forty fifty sixty seventy eighty ninety].freeze
82
+
83
+ # Indian-system amount in words for the slip footer.
84
+ def hrl_amount_in_words(amount)
85
+ rupees = HrLite::Money.round_rupee(amount).to_i
86
+ return "Zero rupees" if rupees.zero?
87
+
88
+ parts = []
89
+ [ [ 10_000_000, "crore" ], [ 100_000, "lakh" ], [ 1000, "thousand" ], [ 100, "hundred" ] ].each do |divisor, label|
90
+ next unless rupees >= divisor
91
+
92
+ parts << "#{hrl_two_digit_words(rupees / divisor)} #{label}"
93
+ rupees %= divisor
94
+ end
95
+ parts << hrl_two_digit_words(rupees) if rupees.positive?
96
+ "#{parts.join(' ').capitalize} rupees"
97
+ end
98
+
99
+ def hrl_two_digit_words(number)
100
+ return ONES[number] if number < 20
101
+
102
+ [ TENS[number / 10], number % 10 == 0 ? nil : ONES[number % 10] ].compact.join("-")
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,4 @@
1
+ module HrLite
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,33 @@
1
+ module HrLite
2
+ # Morning leadership email: who's out, what's pending, what's flagged.
3
+ # Sends nothing on a quiet day.
4
+ class DailyDigestJob < ActiveJob::Base
5
+ queue_as :default
6
+
7
+ def perform(date: Date.current)
8
+ query = OverviewQuery.new(date: date)
9
+ return if query.empty?
10
+
11
+ lines = []
12
+ query.on_leave_today.each do |request|
13
+ lines << "On leave: #{HrLite.display_name(request.user)} — #{request.leave_type.name} (#{request.date_range_label})"
14
+ end
15
+ query.pending_requests.each do |request|
16
+ lines << "Pending approval: #{HrLite.display_name(request.user)} — #{request.leave_type.name} (#{request.date_range_label})"
17
+ end
18
+ query.flagged_today.each do |record|
19
+ lines << "Flagged punch: #{HrLite.display_name(record.user)} — #{record.flag_note}"
20
+ end
21
+ query.missing_checkout_yesterday.each do |record|
22
+ lines << "Missing checkout yesterday: #{HrLite.display_name(record.user)}"
23
+ end
24
+
25
+ Notifications.publish(
26
+ "digest.daily",
27
+ title: "HR daily digest — #{date.strftime('%d %b %Y')}",
28
+ lines: lines,
29
+ path: "/admin/overview"
30
+ )
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,29 @@
1
+ module HrLite
2
+ # Jan-1 rollover: materialize carry-forward into the new year's balance
3
+ # rows. Idempotent — a balance whose carry has already been written is
4
+ # never touched again (manual adjustments stay safe).
5
+ class LeaveYearRolloverJob < ActiveJob::Base
6
+ queue_as :default
7
+
8
+ def perform(year: Date.current.year)
9
+ previous_year = year - 1
10
+
11
+ LeaveType.active.where(paid: true).where.not(annual_quota: nil).find_each do |type|
12
+ next unless type.carry_forward_cap.positive?
13
+
14
+ HrLite.employees.each do |user|
15
+ balance = LeaveBalance.for(user, type, year)
16
+ next if balance.persisted? && balance.carried_forward.positive?
17
+
18
+ carry = LeaveBalance.for(user, type, previous_year)
19
+ .available(as_of: Date.new(previous_year, 12, 31))
20
+ carry = [ [ carry, type.carry_forward_cap ].min, 0 ].max
21
+ next if carry.zero? && balance.persisted?
22
+
23
+ balance.carried_forward = carry
24
+ balance.save!
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ module HrLite
2
+ class ApplicationMailer < ActionMailer::Base
3
+ layout "hr_lite/mailer"
4
+ end
5
+ end