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,74 @@
1
+ module HrLite
2
+ module Admin
3
+ class AppraisalsController < LeadershipController
4
+ before_action :set_profile
5
+
6
+ def new
7
+ @appraisal = Appraisal.new(
8
+ user_id: @profile.user_id,
9
+ period_start: Date.current.beginning_of_year, period_end: Date.current
10
+ )
11
+ end
12
+
13
+ def create
14
+ @appraisal = Appraisal.new(appraisal_params.merge(
15
+ user_id: @profile.user_id, reviewer_id: hr_current_user.id
16
+ ))
17
+ if @appraisal.save
18
+ redirect_to admin_employee_path(@profile), notice: "Appraisal drafted — share when ready."
19
+ else
20
+ render :new, status: :unprocessable_entity
21
+ end
22
+ end
23
+
24
+ def edit
25
+ @appraisal = scoped.find(params[:id])
26
+ end
27
+
28
+ def update
29
+ @appraisal = scoped.find(params[:id])
30
+ if @appraisal.update(appraisal_params)
31
+ redirect_to admin_employee_path(@profile), notice: "Appraisal updated."
32
+ else
33
+ render :edit, status: :unprocessable_entity
34
+ end
35
+ end
36
+
37
+ def share
38
+ appraisal = scoped.find(params[:id])
39
+ appraisal.share!(actor: hr_current_user)
40
+ redirect_to admin_employee_path(@profile), notice: "Appraisal shared with the employee."
41
+ rescue ActiveRecord::RecordInvalid => e
42
+ message = e.record.errors.full_messages.to_sentence.presence || "This appraisal was already shared."
43
+ redirect_to admin_employee_path(@profile), alert: message
44
+ end
45
+
46
+ def destroy
47
+ appraisal = scoped.find(params[:id])
48
+ if appraisal.destroy
49
+ redirect_to admin_employee_path(@profile), notice: "Draft appraisal removed.", status: :see_other
50
+ else
51
+ redirect_to admin_employee_path(@profile), status: :see_other,
52
+ alert: appraisal.errors.full_messages.to_sentence
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def set_profile
59
+ @profile = EmployeeProfile.find(params[:employee_id])
60
+ end
61
+
62
+ def scoped
63
+ Appraisal.where(user_id: @profile.user_id)
64
+ end
65
+
66
+ def appraisal_params
67
+ params.require(:appraisal).permit(
68
+ :period_start, :period_end, :rating, :strengths, :improvements,
69
+ :outcome, :effective_date, :new_designation
70
+ )
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,79 @@
1
+ module HrLite
2
+ module Admin
3
+ class AttendancesController < BaseController
4
+ # Team day view: everyone × their punch/status for one date.
5
+ def index
6
+ @date = parse_date_param(params[:date])
7
+ @employees = HrLite.employees
8
+ @records = AttendanceRecord.for_date(@date).where(user_id: @employees.map(&:id)).index_by(&:user_id)
9
+ @flagged_count = @records.values.count(&:flagged?)
10
+ end
11
+
12
+ # One employee's month + the regularization form for ?date=.
13
+ def show
14
+ @employee = HrLite.user_klass.find(params[:user_id])
15
+ @month = parse_month_param(params[:month])
16
+ @day_status = DayStatus.new(user: @employee, range: @month.beginning_of_month..@month.end_of_month)
17
+ @counts = @day_status.counts
18
+ @edit_date = params[:date].present? ? parse_date_param(params[:date]) : nil
19
+ @edit_record = @edit_date && AttendanceRecord.find_or_initialize_by(user_id: @employee.id, date: @edit_date)
20
+ end
21
+
22
+ # Regularization: fix punches with a mandatory note, fully audited.
23
+ # Clearing both punch times deletes the record (that is how an
24
+ # erroneous punch is removed).
25
+ def update
26
+ @employee = HrLite.user_klass.find(params[:user_id])
27
+ date = parse_date_param(params[:date])
28
+ record = AttendanceRecord.find_or_initialize_by(user_id: @employee.id, date: date)
29
+
30
+ note = params.dig(:attendance_record, :regularization_note).to_s.strip
31
+ if note.blank?
32
+ return redirect_to admin_attendance_path(@employee.id, date: date, month: date.strftime("%Y-%m")),
33
+ alert: "A regularization note is required."
34
+ end
35
+
36
+ attrs = params.require(:attendance_record).permit(:check_in_at, :check_out_at, :status)
37
+ if attrs[:check_in_at].blank? && attrs[:check_out_at].blank?
38
+ record.destroy if record.persisted?
39
+ log_regularization(record, note, removed: true)
40
+ return redirect_to admin_attendance_path(@employee.id, month: date.strftime("%Y-%m")),
41
+ notice: "Punch removed."
42
+ end
43
+
44
+ record.assign_attributes(attrs)
45
+ record.status = "present" if record.status.blank?
46
+ record.regularized_by_id = hr_current_user.id
47
+ record.regularized_at = Time.current
48
+ record.regularization_note = note
49
+
50
+ if record.save
51
+ log_regularization(record, note, removed: false)
52
+ redirect_to admin_attendance_path(@employee.id, month: date.strftime("%Y-%m")),
53
+ notice: "Attendance updated."
54
+ else
55
+ redirect_to admin_attendance_path(@employee.id, date: date, month: date.strftime("%Y-%m")),
56
+ alert: record.errors.full_messages.to_sentence
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def log_regularization(record, note, removed:)
63
+ AuditLog.create!(
64
+ actor: hr_current_user, action: removed ? "destroy" : "regularize",
65
+ subject_type: record.class.name, subject_id: record.id || 0,
66
+ audited_changes: { "date" => record.date.to_s, "note" => note }
67
+ )
68
+ Notifications.publish(
69
+ "attendance.regularized",
70
+ title: "Attendance #{removed ? 'punch removed' : 'regularized'} for #{record.date.strftime('%d %b')}",
71
+ body: note,
72
+ path: "/attendance?month=#{record.date.strftime('%Y-%m')}",
73
+ bell_to: [ record.user ],
74
+ email_to: [ record.user ]
75
+ )
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,9 @@
1
+ module HrLite
2
+ module Admin
3
+ class AuditLogsController < LeadershipController
4
+ def index
5
+ @audit_logs = paginate(AuditLog.recent.includes(:actor))
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ module HrLite
2
+ module Admin
3
+ # Day-to-day operations tier: overview board, team attendance,
4
+ # leave decisions, balance adjustments. Leadership members are
5
+ # allowed too — governing implies at least operational visibility.
6
+ class BaseController < ApplicationController
7
+ before_action :require_hr_admin!
8
+
9
+ private
10
+
11
+ def require_hr_admin!
12
+ hr_access_denied unless hr_admin? || hr_leadership?
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,33 @@
1
+ module HrLite
2
+ module Admin
3
+ # Standalone role change (promotion without an appraisal).
4
+ class DesignationChangesController < LeadershipController
5
+ before_action :set_profile
6
+
7
+ def new
8
+ @change = DesignationChange.new(user_id: @profile.user_id, effective_date: Date.current)
9
+ end
10
+
11
+ def create
12
+ @change = DesignationChange.new(change_params.merge(
13
+ user_id: @profile.user_id, created_by_id: hr_current_user.id
14
+ ))
15
+ if @change.save
16
+ redirect_to admin_employee_path(@profile), notice: "Role change recorded."
17
+ else
18
+ render :new, status: :unprocessable_entity
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def set_profile
25
+ @profile = EmployeeProfile.find(params[:employee_id])
26
+ end
27
+
28
+ def change_params
29
+ params.require(:designation_change).permit(:to_designation, :effective_date, :note)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,53 @@
1
+ module HrLite
2
+ module Admin
3
+ # Employee HR profiles (leadership). No destroy — exits are a date;
4
+ # payroll history is a statutory record.
5
+ class EmployeesController < LeadershipController
6
+ def index
7
+ @profiles = paginate(EmployeeProfile.includes(:user).order(:employee_code))
8
+ @users_without_profile = HrLite.employees.reject { |u| EmployeeProfile.exists?(user_id: u.id) }
9
+ end
10
+
11
+ def show
12
+ @profile = EmployeeProfile.includes(:user).find(params[:id])
13
+ @structures = SalaryStructure.where(user_id: @profile.user_id).order(effective_from: :desc)
14
+ end
15
+
16
+ def new
17
+ @profile = EmployeeProfile.new(user_id: params[:user_id], date_of_joining: Date.current)
18
+ end
19
+
20
+ def create
21
+ @profile = EmployeeProfile.new(profile_params)
22
+ if @profile.save
23
+ redirect_to admin_employee_path(@profile), notice: "Employee profile created."
24
+ else
25
+ render :new, status: :unprocessable_entity
26
+ end
27
+ end
28
+
29
+ def edit
30
+ @profile = EmployeeProfile.find(params[:id])
31
+ end
32
+
33
+ def update
34
+ @profile = EmployeeProfile.find(params[:id])
35
+ if @profile.update(profile_params)
36
+ redirect_to admin_employee_path(@profile), notice: "Employee profile updated."
37
+ else
38
+ render :edit, status: :unprocessable_entity
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def profile_params
45
+ params.require(:employee_profile).permit(
46
+ :user_id, :employee_code, :designation, :date_of_birth, :date_of_joining,
47
+ :date_of_exit, :department, :work_location, :pan_number, :pf_uan, :esi_number,
48
+ :bank_account_number, :bank_ifsc, :bank_name, :tax_regime, :declared_annual_deductions
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,70 @@
1
+ module HrLite
2
+ module Admin
3
+ class HolidaysController < LeadershipController
4
+ def index
5
+ year = params[:year].to_i
6
+ @year = year.between?(2000, 2100) ? year : Date.current.year
7
+ @holidays = Holiday.where(date: Date.new(@year, 1, 1)..Date.new(@year, 12, 31)).order(:date)
8
+ @holiday = Holiday.new
9
+ end
10
+
11
+ def create
12
+ @holiday = Holiday.new(holiday_params)
13
+ if @holiday.save
14
+ redirect_to admin_holidays_path(year: @holiday.date.year), notice: "Holiday added."
15
+ else
16
+ redirect_to admin_holidays_path, alert: @holiday.errors.full_messages.to_sentence
17
+ end
18
+ end
19
+
20
+ def update
21
+ holiday = Holiday.find(params[:id])
22
+ if holiday.update(holiday_params)
23
+ redirect_to admin_holidays_path(year: holiday.date.year), notice: "Holiday updated."
24
+ else
25
+ redirect_to admin_holidays_path, alert: holiday.errors.full_messages.to_sentence
26
+ end
27
+ end
28
+
29
+ def destroy
30
+ holiday = Holiday.find(params[:id])
31
+ holiday.destroy!
32
+ redirect_to admin_holidays_path(year: holiday.date.year),
33
+ notice: "Holiday removed.", status: :see_other
34
+ end
35
+
36
+ # Bulk paste: one "YYYY-MM-DD, Name[, optional]" per line. Reports
37
+ # per-line problems, skips duplicates silently.
38
+ def bulk_create
39
+ created = 0
40
+ problems = []
41
+
42
+ params[:lines].to_s.each_line.with_index(1) do |line, number|
43
+ line = line.strip
44
+ next if line.blank?
45
+
46
+ date_part, name_part, flag = line.split(",", 3).map { |part| part.to_s.strip }
47
+ date = Date.strptime(date_part.to_s, "%Y-%m-%d") rescue nil
48
+ if date.nil? || name_part.blank?
49
+ problems << "Line #{number}: expected YYYY-MM-DD, Name"
50
+ next
51
+ end
52
+ next if Holiday.exists?(date: date)
53
+
54
+ Holiday.create!(date: date, name: name_part, optional: flag.to_s.casecmp?("optional"))
55
+ created += 1
56
+ end
57
+
58
+ message = "#{created} holiday#{'s' unless created == 1} added."
59
+ message += " Problems: #{problems.join('; ')}" if problems.any?
60
+ redirect_to admin_holidays_path, problems.any? ? { alert: message } : { notice: message }
61
+ end
62
+
63
+ private
64
+
65
+ def holiday_params
66
+ params.require(:holiday).permit(:date, :name, :optional)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,18 @@
1
+ module HrLite
2
+ module Admin
3
+ # Governing tier: anything that changes policy or money — leave types,
4
+ # settings, office locations, holidays, employee profiles, salary
5
+ # structures, payroll, appraisals, audit trail. Leadership ONLY;
6
+ # being a host-app admin is not enough.
7
+ class LeadershipController < BaseController
8
+ skip_before_action :require_hr_admin!
9
+ before_action :require_hr_leadership!
10
+
11
+ private
12
+
13
+ def require_hr_leadership!
14
+ hr_access_denied unless hr_leadership?
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,41 @@
1
+ module HrLite
2
+ module Admin
3
+ class LeaveBalancesController < BaseController
4
+ def index
5
+ year = params[:year].to_i
6
+ @year = year.between?(2000, 2100) ? year : Date.current.year
7
+ @types = LeaveType.active.where(paid: true).where.not(annual_quota: nil)
8
+ @employees = HrLite.employees
9
+ end
10
+
11
+ # Manual credit/debit — also the comp-off credit mechanism.
12
+ def adjust
13
+ user = HrLite.user_klass.find(params[:user_id])
14
+ type = LeaveType.find(params[:leave_type_id])
15
+ year = params[:year].to_i
16
+ delta = BigDecimal(params[:delta].to_s)
17
+ note = params[:note].to_s.strip
18
+
19
+ if note.blank?
20
+ return redirect_to admin_leave_balances_path(year: year), alert: "A note is required."
21
+ end
22
+
23
+ balance = LeaveBalance.for(user, type, year)
24
+ balance.adjustment += delta
25
+ balance.adjustment_note = [ balance.adjustment_note.presence, "#{delta.to_f} — #{note}" ].compact.join("; ")
26
+ balance.save!
27
+
28
+ AuditLog.create!(
29
+ actor: hr_current_user, action: "adjust",
30
+ subject_type: balance.class.name, subject_id: balance.id,
31
+ audited_changes: { "user" => HrLite.display_name(user), "type" => type.code,
32
+ "delta" => delta.to_f, "note" => note }
33
+ )
34
+ redirect_to admin_leave_balances_path(year: year),
35
+ notice: "Balance adjusted for #{HrLite.display_name(user)}."
36
+ rescue ArgumentError
37
+ redirect_to admin_leave_balances_path, alert: "Enter a valid adjustment number."
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,41 @@
1
+ module HrLite
2
+ module Admin
3
+ class LeaveRequestsController < BaseController
4
+ def index
5
+ scope = LeaveRequest.includes(:leave_type, :user).recent_first
6
+ @status = params[:status].presence_in(LeaveRequest::STATUSES) || "pending"
7
+ @requests = paginate(scope.where(status: @status))
8
+ end
9
+
10
+ def show
11
+ @request = LeaveRequest.includes(:leave_type).find(params[:id])
12
+ @balance = @request.balance
13
+ end
14
+
15
+ def approve
16
+ request = LeaveRequest.find(params[:id])
17
+ if request.approve!(actor: hr_current_user, note: params[:decision_note].presence)
18
+ redirect_to admin_leave_requests_path, notice: "Leave approved."
19
+ else
20
+ redirect_to admin_leave_request_path(request),
21
+ alert: "Cannot approve — balance no longer covers this request."
22
+ end
23
+ rescue ActiveRecord::RecordInvalid
24
+ redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
25
+ end
26
+
27
+ def reject
28
+ request = LeaveRequest.find(params[:id])
29
+ note = params[:decision_note].to_s.strip
30
+ if note.blank?
31
+ return redirect_to admin_leave_request_path(request), alert: "A note is required to reject."
32
+ end
33
+
34
+ request.reject!(actor: hr_current_user, note: note)
35
+ redirect_to admin_leave_requests_path, notice: "Leave rejected."
36
+ rescue ActiveRecord::RecordInvalid
37
+ redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,54 @@
1
+ module HrLite
2
+ module Admin
3
+ class LeaveTypesController < LeadershipController
4
+ def index
5
+ @leave_types = LeaveType.order(:position, :id)
6
+ end
7
+
8
+ def new
9
+ @leave_type = LeaveType.new
10
+ end
11
+
12
+ def create
13
+ @leave_type = LeaveType.new(leave_type_params)
14
+ if @leave_type.save
15
+ redirect_to admin_leave_types_path, notice: "Leave type created."
16
+ else
17
+ render :new, status: :unprocessable_entity
18
+ end
19
+ end
20
+
21
+ def edit
22
+ @leave_type = LeaveType.find(params[:id])
23
+ end
24
+
25
+ def update
26
+ @leave_type = LeaveType.find(params[:id])
27
+ if @leave_type.update(leave_type_params)
28
+ redirect_to admin_leave_types_path, notice: "Leave type updated."
29
+ else
30
+ render :edit, status: :unprocessable_entity
31
+ end
32
+ end
33
+
34
+ # Types with history cannot be destroyed — deactivate instead.
35
+ def destroy
36
+ leave_type = LeaveType.find(params[:id])
37
+ if leave_type.destroy
38
+ redirect_to admin_leave_types_path, notice: "Leave type removed.", status: :see_other
39
+ else
40
+ redirect_to admin_leave_types_path, status: :see_other,
41
+ alert: "This type has leave history — deactivate it instead."
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def leave_type_params
48
+ params.require(:leave_type)
49
+ .permit(:name, :code, :color, :paid, :annual_quota, :accrual,
50
+ :carry_forward_cap, :active, :position)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,46 @@
1
+ module HrLite
2
+ module Admin
3
+ class OfficeLocationsController < LeadershipController
4
+ def index
5
+ @office_locations = OfficeLocation.order(:name)
6
+ end
7
+
8
+ def new
9
+ @office_location = OfficeLocation.new
10
+ end
11
+
12
+ def create
13
+ @office_location = OfficeLocation.new(office_location_params)
14
+ if @office_location.save
15
+ redirect_to admin_office_locations_path, notice: "Office added."
16
+ else
17
+ render :new, status: :unprocessable_entity
18
+ end
19
+ end
20
+
21
+ def edit
22
+ @office_location = OfficeLocation.find(params[:id])
23
+ end
24
+
25
+ def update
26
+ @office_location = OfficeLocation.find(params[:id])
27
+ if @office_location.update(office_location_params)
28
+ redirect_to admin_office_locations_path, notice: "Office updated."
29
+ else
30
+ render :edit, status: :unprocessable_entity
31
+ end
32
+ end
33
+
34
+ def destroy
35
+ OfficeLocation.find(params[:id]).destroy!
36
+ redirect_to admin_office_locations_path, notice: "Office removed.", status: :see_other
37
+ end
38
+
39
+ private
40
+
41
+ def office_location_params
42
+ params.require(:office_location).permit(:name, :lat, :lng, :radius_m, :active)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,12 @@
1
+ module HrLite
2
+ module Admin
3
+ class OverviewController < BaseController
4
+ SECTION_CAP = 10
5
+
6
+ def index
7
+ @query = OverviewQuery.new
8
+ @kpis = @query.kpis
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,93 @@
1
+ module HrLite
2
+ module Admin
3
+ class PayrollRunsController < LeadershipController
4
+ def index
5
+ @runs = paginate(PayrollRun.recent_first)
6
+ end
7
+
8
+ def new
9
+ @run = PayrollRun.new(period_month: Date.current.prev_month.beginning_of_month)
10
+ end
11
+
12
+ def create
13
+ month = parse_month_param(params.dig(:payroll_run, :period_month))
14
+ @run = PayrollRun.new(period_month: month, created_by_id: hr_current_user.id,
15
+ notes: params.dig(:payroll_run, :notes))
16
+ if @run.save
17
+ redirect_to admin_payroll_run_path(@run), notice: "Run created — compute when ready."
18
+ else
19
+ render :new, status: :unprocessable_entity
20
+ end
21
+ end
22
+
23
+ def show
24
+ @run = PayrollRun.find(params[:id])
25
+ @slips = @run.salary_slips.includes(:user).sort_by { |slip| HrLite.display_name(slip.user) }
26
+ end
27
+
28
+ def destroy
29
+ run = PayrollRun.find(params[:id])
30
+ if run.destroy
31
+ redirect_to admin_payroll_runs_path, notice: "Draft run deleted.", status: :see_other
32
+ else
33
+ redirect_to admin_payroll_run_path(run), alert: run.errors.full_messages.to_sentence,
34
+ status: :see_other
35
+ end
36
+ end
37
+
38
+ def compute
39
+ transition { |run| run.compute!(actor: hr_current_user) && "Computed — review the slips." }
40
+ end
41
+
42
+ def finalize
43
+ transition { |run| run.finalize!(actor: hr_current_user) && "Run finalized." }
44
+ end
45
+
46
+ def unlock
47
+ transition { |run| run.unlock!(actor: hr_current_user) && "Back in review." }
48
+ end
49
+
50
+ def publish
51
+ transition { |run| run.publish!(actor: hr_current_user) && "Published — employees notified." }
52
+ end
53
+
54
+ # Payout register: the full money sheet including bank details —
55
+ # deliberately leadership-only PII.
56
+ def register
57
+ run = PayrollRun.find(params[:id])
58
+ send_data register_csv(run), filename: "payroll-register-#{run.period_month.strftime('%Y-%m')}.csv",
59
+ type: "text/csv"
60
+ end
61
+
62
+ private
63
+
64
+ def transition
65
+ run = PayrollRun.find(params[:id])
66
+ notice = yield(run)
67
+ redirect_to admin_payroll_run_path(run), notice: notice
68
+ rescue ActiveRecord::RecordInvalid
69
+ redirect_to admin_payroll_run_path(run), alert: "That step is not available from #{run.status}."
70
+ end
71
+
72
+ def register_csv(run)
73
+ require "csv"
74
+ CSV.generate do |csv|
75
+ csv << [ "Code", "Name", "Days", "LOP", "Gross", "PF", "ESI", "PT", "TDS",
76
+ "Net pay", "Bank", "Account", "IFSC" ]
77
+ run.salary_slips.includes(:user).each do |slip|
78
+ profile = slip.user_profile
79
+ deductions = slip.deductions_rows.index_by { |row| row["code"] }
80
+ csv << [
81
+ profile&.employee_code, HrLite.display_name(slip.user),
82
+ slip.payable_days, slip.effective_lop_days,
83
+ slip.gross_earnings, deductions.dig("pf_employee", "amount") || 0,
84
+ deductions.dig("esi_employee", "amount") || 0, deductions.dig("pt", "amount") || 0,
85
+ deductions.dig("tds", "amount") || 0, slip.net_pay,
86
+ profile&.bank_name, profile&.bank_account_number, profile&.bank_ifsc
87
+ ]
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end