hr_lite 0.25.0 → 0.26.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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +2 -0
- data/app/controllers/hr_lite/admin/ca_reviews_controller.rb +76 -0
- data/app/controllers/hr_lite/admin/salary_slips_controller.rb +4 -0
- data/app/helpers/hr_lite/application_helper.rb +2 -0
- data/app/jobs/hr_lite/ca_review_overdue_job.rb +19 -0
- data/app/jobs/hr_lite/ca_review_reminder_job.rb +21 -0
- data/app/models/hr_lite/audit_log.rb +1 -1
- data/app/models/hr_lite/ca_review.rb +66 -0
- data/app/models/hr_lite/role.rb +3 -0
- data/app/views/hr_lite/admin/ca_reviews/index.html.erb +94 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20261010110000_create_hr_lite_ca_reviews.rb +21 -0
- data/db/migrate/20261010110100_seed_hr_lite_ca_role.rb +14 -0
- data/lib/hr_lite/notifications.rb +3 -0
- data/lib/hr_lite/permissions.rb +1 -0
- data/lib/hr_lite/role_seeds.rb +6 -0
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +9 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 54c475874de00a0db443998c37e4caeb3340e202584e094db29ef423c4902698
|
|
4
|
+
data.tar.gz: 849526338af6b16a5648a5163d140f0cda40b2fa3c34ef2a63a3d2f454761132
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38bf1cd87f1e283d7e8a54e60a18a19020fae7ff52feca24941f94dc40673c29d3a08a99d8c82699353f9958a99ffaf39ab8ae0a6c2e5e237ee1d96a567ae0c7
|
|
7
|
+
data.tar.gz: 390843351f21b51a9f2f17f1159214035349ecf8a689722d8dd429b803775715b322d6914fc251bb1808ad03c09c569f55beb0c85b634de8fefa41069b6317f1
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.26.0] - 2026-10-10
|
|
11
|
+
|
|
12
|
+
Two migrations: `hr_lite_ca_reviews`, and a data migration creating the CA role and giving Super Admin the new `payroll.verify` permission. Schedule `CaReviewReminderJob` weekly and `CaReviewOverdueJob` on 31 March.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- CA year-end review: a new **CA** role for an outside chartered accountant (reads every slip, salary structure and tax declaration with its proofs; verifies; nothing else). CA logins without an HR profile are not staff — `HrLite.employees` leaves them out, so attendance, payroll, team screens and broadcasts never count them.
|
|
16
|
+
- "CA review" (`/admin/ca_reviews`): progress for the financial year (slips and declarations verified, flags open), tabs for salary slips and tax declarations, filters (to review, flagged, verified, resolved), Verify, Flag with a required note, and "Verify all shown". A flag tells approvers and payroll (`ca.flagged`); they fix it the normal way and Mark resolved with a note. A published slip is never edited. `HrLite::CaReview`.
|
|
17
|
+
- `CaReviewReminderJob` (weekly, acts only in March) and `CaReviewOverdueJob` (31 March): what is left, to the CA reviewers and then to payroll and HR.
|
|
18
|
+
- A CA (`payroll.view`, all) can open any slip read-only.
|
|
19
|
+
|
|
10
20
|
## [0.25.0] - 2026-10-10
|
|
11
21
|
|
|
12
22
|
Three migrations: `senior` and `landlord_pan` (encrypted) on declaration lines; a data migration adding the FY 2026-27 and October 2026 rate cards to installs that already have cards; a data migration giving HR `tax.view` and `tax.manage` (all).
|
data/README.md
CHANGED
|
@@ -382,6 +382,8 @@ whatever you run). Each is idempotent and sends nothing on a quiet day.
|
|
|
382
382
|
|---|---|---|
|
|
383
383
|
| `HrLite::DailyDigestJob` | each morning | Leadership digest: who is out today, pending approvals, flagged punches, missing checkouts |
|
|
384
384
|
| `HrLite::PayrollAutoDraftJob` | monthly, on the 1st | Draft and compute the previous month's payroll from attendance, then notify the approvers (`payroll.approve`) and payroll operators |
|
|
385
|
+
| `HrLite::CaReviewReminderJob` | weekly (it acts only in March) | Tell CA reviewers how many slips and declarations are still unverified before 31 March |
|
|
386
|
+
| `HrLite::CaReviewOverdueJob` | 31 March, evening | Tell payroll and HR what the CA has not verified, and any flags left open |
|
|
385
387
|
| `HrLite::PayrollAutoApproveJob` | monthly, on the 3rd | Approve and publish last month's run if nobody finished approving it — unless it has blocking warnings (no salary structure, no PT slabs, a stale or unverified statutory card), when the approvers are told it is overdue instead |
|
|
386
388
|
| `HrLite::LeaveYearRolloverJob` | leave year's first day (Jan 1, or Jul 1 for a July–June year) | Materialize carry-forward into the new year's balances |
|
|
387
389
|
| `HrLite::AttendanceCloseJob` | each night, `55 23 * * * Asia/Kolkata` | Close the day: an open punch is checked out at 23:59 and marked half day; a run before noon (a retry, or a morning schedule that lets night shifts check out first) closes the previous day instead; nobody checked in stays absent. Each is emailed how to fix it. Skips weekends, holidays and approved leave. Run it once a day — a re-run tells the no-shows again. |
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# The CA's year-end pass over every slip and declaration. Reviewers
|
|
4
|
+
# (payroll.verify) verify or flag; approvers and payroll resolve flags.
|
|
5
|
+
class CaReviewsController < BaseController
|
|
6
|
+
skip_before_action :require_operations_access!
|
|
7
|
+
before_action -> { require_any!("payroll.verify", "payroll.approve", "payroll.manage") }, only: :index
|
|
8
|
+
before_action -> { require_any!("payroll.verify") }, only: %i[verify flag verify_all]
|
|
9
|
+
before_action -> { require_any!("payroll.approve", "payroll.manage") }, only: :resolve
|
|
10
|
+
|
|
11
|
+
def index
|
|
12
|
+
@fy = FinancialYear.start_for(params[:fy].present? ? parse_date_param(params[:fy]) : Date.current)
|
|
13
|
+
@tab = params[:tab] == "declarations" ? "declarations" : "slips"
|
|
14
|
+
@status = params[:status].presence_in(%w[unreviewed verified flagged resolved])
|
|
15
|
+
scope = @tab == "slips" ? CaReview.slips_for(@fy).includes(:user, :payroll_run).order("hr_lite_payroll_runs.period_month", :user_id) : CaReview.declarations_for(@fy).includes(:user).order(:user_id)
|
|
16
|
+
type = scope.klass.name
|
|
17
|
+
reviewed = CaReview.where(subject_type: type)
|
|
18
|
+
scope = if @status == "unreviewed"
|
|
19
|
+
scope.where.not(id: reviewed.select(:subject_id))
|
|
20
|
+
elsif @status
|
|
21
|
+
scope.where(id: reviewed.where(status: @status).select(:subject_id))
|
|
22
|
+
else
|
|
23
|
+
scope
|
|
24
|
+
end
|
|
25
|
+
@rows = paginate(scope)
|
|
26
|
+
@reviews = reviewed.where(subject_id: @rows.map(&:id)).index_by(&:subject_id)
|
|
27
|
+
@left = CaReview.outstanding(@fy)
|
|
28
|
+
@totals = { "slips" => CaReview.slips_for(@fy).count, "declarations" => CaReview.declarations_for(@fy).count }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def verify
|
|
32
|
+
CaReview.verify!(subject, actor: hr_current_user)
|
|
33
|
+
redirect_back_to_list notice: "Verified."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def flag
|
|
37
|
+
return redirect_back_to_list(alert: "Say what is wrong — HR has to know what to fix.") if params[:note].blank?
|
|
38
|
+
|
|
39
|
+
CaReview.flag!(subject, actor: hr_current_user, note: params[:note])
|
|
40
|
+
redirect_back_to_list notice: "Flagged — HR and payroll have been told."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# "Verify all shown": the unreviewed rows on the page the CA was looking at.
|
|
44
|
+
def verify_all
|
|
45
|
+
model = params[:subject_type] == "HrLite::TaxDeclaration" ? TaxDeclaration : SalarySlip
|
|
46
|
+
reviewed = CaReview.where(subject_type: model.name, subject_id: params[:ids]).pluck(:subject_id)
|
|
47
|
+
model.where(id: Array(params[:ids]) - reviewed.map(&:to_s)).find_each { |s| CaReview.verify!(s, actor: hr_current_user) }
|
|
48
|
+
redirect_back_to_list notice: "Verified everything shown."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def resolve
|
|
52
|
+
CaReview.find(params[:id]).resolve!(actor: hr_current_user, note: params[:note])
|
|
53
|
+
redirect_back_to_list notice: "Marked resolved."
|
|
54
|
+
rescue ActiveRecord::RecordInvalid
|
|
55
|
+
redirect_back_to_list alert: "Only a flagged item can be resolved."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def require_any!(*keys)
|
|
61
|
+
return if keys.any? { |key| hr_can?(key, scope: :all) }
|
|
62
|
+
|
|
63
|
+
hr_require_permission!(keys.first, scope: :all)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def subject
|
|
67
|
+
model = params[:subject_type] == "HrLite::TaxDeclaration" ? TaxDeclaration : SalarySlip
|
|
68
|
+
model.find(params[:subject_id])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def redirect_back_to_list(**flash)
|
|
72
|
+
redirect_to admin_ca_reviews_path(params.permit(:fy, :tab, :status, :page).to_h), **flash
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module Admin
|
|
3
3
|
class SalarySlipsController < SuperadminController
|
|
4
|
+
# A CA reads a slip to verify it (payroll.view, all) without running payroll.
|
|
5
|
+
skip_before_action :require_money_access!, only: :show
|
|
6
|
+
before_action -> { hr_require_permission!("payroll.view", scope: :all) }, only: :show
|
|
7
|
+
|
|
4
8
|
def show
|
|
5
9
|
@slip = SalarySlip.includes(:payroll_run).find(params[:id])
|
|
6
10
|
@profile = @slip.user_profile
|
|
@@ -18,6 +18,8 @@ module HrLite
|
|
|
18
18
|
{ label: "ID card", path: :id_card_path, match: [ "/id_card" ], group: "Me" },
|
|
19
19
|
{ label: "Career", path: :career_path, match: [ "/career", "/appraisals" ], group: "Me" },
|
|
20
20
|
{ label: "Timeline", path: :timeline_path, match: [ "/timeline" ], group: "Me" },
|
|
21
|
+
{ label: "CA review", path: :admin_ca_reviews_path, match: [ "/admin/ca_reviews" ], group: "Company",
|
|
22
|
+
if: -> { hr_can?("payroll.verify", scope: :all) || hr_can?("payroll.approve", scope: :all) } },
|
|
21
23
|
{ label: "Policies", path: :policies_path, match: [ "/policies" ], group: "Company" },
|
|
22
24
|
{ label: "Ask HR", path: :hr_requests_path, match: [ "/hr_requests" ], group: "Company" },
|
|
23
25
|
{ label: "Org", path: :org_path, match: [ "/org" ], group: "Company" },
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Schedule on 31 March evening: anything the CA has not verified, or a flag
|
|
3
|
+
# nobody resolved, goes to payroll and HR as overdue.
|
|
4
|
+
class CaReviewOverdueJob < ApplicationJob
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
def perform(on: Date.current)
|
|
8
|
+
left = CaReview.outstanding(FinancialYear.start_for(on))
|
|
9
|
+
return if left.values.sum.zero?
|
|
10
|
+
|
|
11
|
+
people = HrLite.users_holding("payroll.approve", scope: :all).to_a | HrLite.users_holding("payroll.manage", scope: :all).to_a
|
|
12
|
+
Notifications.publish(
|
|
13
|
+
"ca.overdue",
|
|
14
|
+
title: "CA review overdue: #{left['slips']} slips, #{left['declarations']} declarations unverified, #{left['flagged']} flags open",
|
|
15
|
+
path: "/admin/ca_reviews?status=unreviewed", bell_to: people, email_to: people
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Schedule weekly in March: tells the CA reviewers what is left before 31 March.
|
|
3
|
+
class CaReviewReminderJob < ApplicationJob
|
|
4
|
+
queue_as :default
|
|
5
|
+
|
|
6
|
+
def perform(on: Date.current)
|
|
7
|
+
return unless on.month == 3
|
|
8
|
+
|
|
9
|
+
left = CaReview.outstanding(FinancialYear.start_for(on))
|
|
10
|
+
return if left.values.sum.zero?
|
|
11
|
+
|
|
12
|
+
reviewers = HrLite.users_holding("payroll.verify", scope: :all).to_a
|
|
13
|
+
Notifications.publish(
|
|
14
|
+
"ca.reminder",
|
|
15
|
+
title: "CA review: #{left['slips']} slips and #{left['declarations']} declarations left before 31 March" \
|
|
16
|
+
"#{", #{left['flagged']} flagged" if left['flagged'].positive?}",
|
|
17
|
+
path: "/admin/ca_reviews", bell_to: reviewers, email_to: reviewers
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -15,7 +15,7 @@ module HrLite
|
|
|
15
15
|
MONEY_TIER_TYPES = %w[
|
|
16
16
|
HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
|
|
17
17
|
HrLite::PayrollRun HrLite::SalarySlip HrLite::PayrollLineItem HrLite::Loan
|
|
18
|
-
HrLite::PerformancePlan
|
|
18
|
+
HrLite::PerformancePlan HrLite::CaReview
|
|
19
19
|
].freeze
|
|
20
20
|
|
|
21
21
|
scope :recent, -> { order(created_at: :desc) }
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A chartered accountant's year-end mark on one salary slip or tax
|
|
3
|
+
# declaration. Verifying is final; a flag says what is wrong and stays open
|
|
4
|
+
# until HR or payroll fixes it the normal way (a correcting line on a later
|
|
5
|
+
# slip, or reopening the declaration) and resolves it. The slip itself is
|
|
6
|
+
# never edited.
|
|
7
|
+
class CaReview < ApplicationRecord
|
|
8
|
+
include Audited
|
|
9
|
+
|
|
10
|
+
SUBJECTS = %w[HrLite::SalarySlip HrLite::TaxDeclaration].freeze
|
|
11
|
+
STATUSES = %w[verified flagged resolved].freeze
|
|
12
|
+
|
|
13
|
+
belongs_to :subject, polymorphic: true
|
|
14
|
+
belongs_to :reviewed_by, class_name: HrLite.config.user_class
|
|
15
|
+
belongs_to :resolved_by, class_name: HrLite.config.user_class, optional: true
|
|
16
|
+
|
|
17
|
+
validates :subject_type, inclusion: { in: SUBJECTS }
|
|
18
|
+
validates :status, inclusion: { in: STATUSES }
|
|
19
|
+
validates :note, presence: true, if: -> { status == "flagged" }
|
|
20
|
+
|
|
21
|
+
# What the CA reviews for a financial year: every published slip, and
|
|
22
|
+
# every declaration somebody actually filed.
|
|
23
|
+
def self.slips_for(fy)
|
|
24
|
+
SalarySlip.joins(:payroll_run).where(hr_lite_payroll_runs: { status: "published", period_month: fy...fy.next_year })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.declarations_for(fy) = TaxDeclaration.where(financial_year: fy).where.not(status: "draft")
|
|
28
|
+
|
|
29
|
+
# Still to do before 31 March: never reviewed, or flagged and not resolved.
|
|
30
|
+
def self.outstanding(fy)
|
|
31
|
+
{ "slips" => slips_for(fy), "declarations" => declarations_for(fy) }.transform_values do |scope|
|
|
32
|
+
done = where(subject: scope, status: %w[verified resolved]).count
|
|
33
|
+
scope.count - done
|
|
34
|
+
end.merge("flagged" => where(subject_type: SUBJECTS, status: "flagged").count)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.verify!(subject, actor:)
|
|
38
|
+
review = find_or_initialize_by(subject: subject)
|
|
39
|
+
review.update!(status: "verified", note: nil, reviewed_by_id: actor.id, reviewed_at: Time.current)
|
|
40
|
+
review
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.flag!(subject, actor:, note:)
|
|
44
|
+
review = find_or_initialize_by(subject: subject)
|
|
45
|
+
review.update!(status: "flagged", note: note.to_s.strip, reviewed_by_id: actor.id, reviewed_at: Time.current,
|
|
46
|
+
resolved_by_id: nil, resolved_at: nil, resolution_note: nil)
|
|
47
|
+
people = (HrLite.users_holding("payroll.approve", scope: :all).to_a | HrLite.users_holding("payroll.manage", scope: :all).to_a)
|
|
48
|
+
Notifications.publish("ca.flagged", title: "CA flagged #{review.subject_label}", body: review.note,
|
|
49
|
+
path: "/admin/ca_reviews?status=flagged", bell_to: people, email_to: people)
|
|
50
|
+
review
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def resolve!(actor:, note:)
|
|
54
|
+
raise ActiveRecord::RecordInvalid.new(self), "not flagged" unless status == "flagged"
|
|
55
|
+
|
|
56
|
+
update!(status: "resolved", resolved_by_id: actor.id, resolved_at: Time.current, resolution_note: note.to_s.strip.presence)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def subject_label
|
|
60
|
+
case subject
|
|
61
|
+
when SalarySlip then "#{HrLite.display_name(subject.user)}'s #{subject.period_month.strftime('%B %Y')} slip"
|
|
62
|
+
else "#{HrLite.display_name(subject.user)}'s #{subject.label} tax declaration"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/app/models/hr_lite/role.rb
CHANGED
|
@@ -12,6 +12,9 @@ module HrLite
|
|
|
12
12
|
FINANCE = "Finance".freeze
|
|
13
13
|
LEADERSHIP = "Leadership".freeze
|
|
14
14
|
SUPER_ADMIN = "Super Admin".freeze
|
|
15
|
+
# An outside chartered accountant: reads pay and tax to verify them at
|
|
16
|
+
# year end. Not an employee — see HrLite.employees.
|
|
17
|
+
CA = "CA".freeze
|
|
15
18
|
|
|
16
19
|
has_many :role_grants, dependent: :destroy
|
|
17
20
|
has_many :role_assignments, dependent: :destroy
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<% content_for(:page_title) { "CA review" } %>
|
|
2
|
+
<% fy_label = HrLite::FinancialYear.label(@fy) %>
|
|
3
|
+
<% reviewer = hr_can?("payroll.verify", scope: :all) %>
|
|
4
|
+
<% resolver = hr_can?("payroll.approve", scope: :all) || hr_can?("payroll.manage", scope: :all) %>
|
|
5
|
+
<% keep = ->(extra = {}) { { fy: @fy, tab: @tab, status: @status }.merge(extra).compact } %>
|
|
6
|
+
<div class="hrl-page__head">
|
|
7
|
+
<h1 class="hrl-page__title">CA review — FY <%= fy_label %></h1>
|
|
8
|
+
</div>
|
|
9
|
+
<p class="hrl-small hrl-muted">Every published salary slip and every filed tax declaration, verified by the CA before 31 March <%= @fy.year + 1 %>. A flag goes to HR and payroll; the fix is a correcting line on a later slip or reopening the declaration — a published slip is never edited.</p>
|
|
10
|
+
|
|
11
|
+
<div class="hrl-kpis">
|
|
12
|
+
<% %w[slips declarations].each do |kind| %>
|
|
13
|
+
<% total = @totals[kind] %>
|
|
14
|
+
<% done = total - @left[kind] %>
|
|
15
|
+
<div class="hrl-kpi">
|
|
16
|
+
<div class="hrl-kpi__value"><%= done %> / <%= total %></div>
|
|
17
|
+
<div class="hrl-kpi__label"><%= kind == "slips" ? "Salary slips" : "Tax declarations" %> verified</div>
|
|
18
|
+
<progress class="hrl-tax-cap" max="<%= [ total, 1 ].max %>" value="<%= done %>" aria-hidden="true"></progress>
|
|
19
|
+
</div>
|
|
20
|
+
<% end %>
|
|
21
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @left["flagged"] %></div><div class="hrl-kpi__label">Flags open</div></div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="hrl-row hrl-row--mb" role="tablist">
|
|
25
|
+
<a class="hrl-btn <%= "hrl-btn--primary" if @tab == "slips" %>" href="<%= hr_lite.admin_ca_reviews_path(keep.(tab: "slips", status: @status)) %>" aria-current="<%= @tab == "slips" ? "page" : nil %>">Salary slips</a>
|
|
26
|
+
<a class="hrl-btn <%= "hrl-btn--primary" if @tab == "declarations" %>" href="<%= hr_lite.admin_ca_reviews_path(keep.(tab: "declarations", status: @status)) %>" aria-current="<%= @tab == "declarations" ? "page" : nil %>">Tax declarations</a>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="hrl-row hrl-row--mb">
|
|
29
|
+
<% [ [ nil, "All" ], [ "unreviewed", "To review" ], [ "flagged", "Flagged" ], [ "verified", "Verified" ], [ "resolved", "Resolved" ] ].each do |value, label| %>
|
|
30
|
+
<a class="hrl-btn <%= "hrl-btn--primary" if @status == value %>" href="<%= hr_lite.admin_ca_reviews_path(keep.(status: value)) %>"><%= label %></a>
|
|
31
|
+
<% end %>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<section class="hrl-card">
|
|
35
|
+
<% if @rows.empty? %>
|
|
36
|
+
<p class="hrl-card--empty">Nothing here.</p>
|
|
37
|
+
<% else %>
|
|
38
|
+
<% subject_type = @rows.first.class.name %>
|
|
39
|
+
<% if reviewer && @rows.any? { |row| @reviews[row.id].nil? } %>
|
|
40
|
+
<%= button_to "Verify all shown", hr_lite.verify_all_admin_ca_reviews_path, method: :post, class: "hrl-btn",
|
|
41
|
+
params: keep.(subject_type: subject_type, ids: @rows.map(&:id)),
|
|
42
|
+
form: { data: { turbo_confirm: "Verify every unreviewed item on this page?" } } %>
|
|
43
|
+
<% end %>
|
|
44
|
+
<ul class="hrl-feed">
|
|
45
|
+
<% @rows.each do |row| %>
|
|
46
|
+
<% review = @reviews[row.id] %>
|
|
47
|
+
<li class="hrl-feed__item">
|
|
48
|
+
<div class="hrl-feed__body">
|
|
49
|
+
<div class="hrl-row">
|
|
50
|
+
<% if row.is_a?(HrLite::SalarySlip) %>
|
|
51
|
+
<a class="hrl-card__link" href="<%= hr_lite.admin_salary_slip_path(row) %>"><%= hr_display_name(row.user) %> — <%= row.period_month.strftime("%B %Y") %></a>
|
|
52
|
+
<span class="hrl-small hrl-muted">net <%= hrl_money(row.net_pay) %></span>
|
|
53
|
+
<% else %>
|
|
54
|
+
<a class="hrl-card__link" href="<%= hr_lite.admin_tax_declaration_path(row) %>"><%= hr_display_name(row.user) %> — <%= row.label %></a>
|
|
55
|
+
<span class="hrl-small hrl-muted"><%= row.regime.humanize %> regime · <%= row.status.humanize %></span>
|
|
56
|
+
<% end %>
|
|
57
|
+
<span class="hrl-right">
|
|
58
|
+
<% badge = { "verified" => [ "hrl-badge--ok", "Verified" ], "flagged" => [ "hrl-badge--bad", "Flagged" ],
|
|
59
|
+
"resolved" => [ "", "Resolved" ] }.fetch(review&.status, [ "hrl-badge--muted", "To review" ]) %>
|
|
60
|
+
<span class="hrl-badge <%= badge.first %>"><%= badge.last %></span>
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
<% if review&.note.present? %><p class="hrl-small">CA: <%= review.note %></p><% end %>
|
|
64
|
+
<% if review&.resolution_note.present? %><p class="hrl-small hrl-muted">Resolved by <%= hr_display_name(review.resolved_by) %>: <%= review.resolution_note %></p><% end %>
|
|
65
|
+
<div class="hrl-row hrl-mt">
|
|
66
|
+
<% if reviewer && review&.status != "verified" %>
|
|
67
|
+
<%= button_to "Verify", hr_lite.verify_admin_ca_reviews_path, method: :post, class: "hrl-btn",
|
|
68
|
+
params: keep.(subject_type: subject_type, subject_id: row.id) %>
|
|
69
|
+
<% end %>
|
|
70
|
+
<% if reviewer %>
|
|
71
|
+
<details>
|
|
72
|
+
<summary class="hrl-btn">Flag…</summary>
|
|
73
|
+
<%= form_with url: hr_lite.flag_admin_ca_reviews_path, method: :post, local: true do %>
|
|
74
|
+
<% keep.(subject_type: subject_type, subject_id: row.id).each do |key, value| %><%= hidden_field_tag key, value %><% end %>
|
|
75
|
+
<div class="hrl-field"><%= label_tag "note_#{row.id}", "What is wrong" %><%= text_area_tag :note, nil, id: "note_#{row.id}", required: true, rows: 2 %></div>
|
|
76
|
+
<%= submit_tag "Flag it", class: "hrl-btn hrl-btn--primary" %>
|
|
77
|
+
<% end %>
|
|
78
|
+
</details>
|
|
79
|
+
<% end %>
|
|
80
|
+
<% if resolver && review&.status == "flagged" %>
|
|
81
|
+
<%= form_with url: hr_lite.resolve_admin_ca_review_path(review, keep.()), method: :post, local: true, class: "hrl-row" do %>
|
|
82
|
+
<%= label_tag "resolution_#{review.id}", "How it was fixed", class: "hrl-sr-only" %>
|
|
83
|
+
<%= text_field_tag :note, nil, id: "resolution_#{review.id}", placeholder: "How it was fixed" %>
|
|
84
|
+
<%= submit_tag "Mark resolved", class: "hrl-btn" %>
|
|
85
|
+
<% end %>
|
|
86
|
+
<% end %>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</li>
|
|
90
|
+
<% end %>
|
|
91
|
+
</ul>
|
|
92
|
+
<%= hrl_pagination %>
|
|
93
|
+
<% end %>
|
|
94
|
+
</section>
|
data/config/routes.rb
CHANGED
|
@@ -122,6 +122,10 @@ HrLite::Engine.routes.draw do
|
|
|
122
122
|
end
|
|
123
123
|
resources :performance_plans, only: %i[index new create edit update]
|
|
124
124
|
resources :awards, only: %i[index new create edit update]
|
|
125
|
+
resources :ca_reviews, only: :index do
|
|
126
|
+
collection { post :verify; post :flag; post :verify_all }
|
|
127
|
+
member { post :resolve }
|
|
128
|
+
end
|
|
125
129
|
resources :payroll_runs, only: %i[index show new create destroy] do
|
|
126
130
|
member do
|
|
127
131
|
post :compute
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class CreateHrLiteCaReviews < ActiveRecord::Migration[8.1]
|
|
2
|
+
# The CA's year-end mark on one salary slip or tax declaration: verified,
|
|
3
|
+
# or flagged with what is wrong until HR/payroll resolves it.
|
|
4
|
+
def change
|
|
5
|
+
create_table :hr_lite_ca_reviews do |t|
|
|
6
|
+
t.string :subject_type, null: false
|
|
7
|
+
t.bigint :subject_id, null: false
|
|
8
|
+
# verified | flagged | resolved
|
|
9
|
+
t.string :status, null: false
|
|
10
|
+
t.text :note
|
|
11
|
+
t.bigint :reviewed_by_id, null: false
|
|
12
|
+
t.datetime :reviewed_at, null: false
|
|
13
|
+
t.bigint :resolved_by_id
|
|
14
|
+
t.datetime :resolved_at
|
|
15
|
+
t.text :resolution_note
|
|
16
|
+
t.timestamps
|
|
17
|
+
end
|
|
18
|
+
add_index :hr_lite_ca_reviews, %i[subject_type subject_id], unique: true
|
|
19
|
+
add_check_constraint :hr_lite_ca_reviews, "status IN ('verified', 'flagged', 'resolved')", name: "hr_lite_ca_reviews_status_check"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class SeedHrLiteCaRole < ActiveRecord::Migration[8.1]
|
|
2
|
+
# The CA role is new in 0.26.0 and payroll.verify is a new key. RoleSeeds
|
|
3
|
+
# creates the missing role; Super Admin, an existing role, gets the key here.
|
|
4
|
+
def up
|
|
5
|
+
return unless HrLite::Role.exists?
|
|
6
|
+
|
|
7
|
+
require "hr_lite/role_seeds"
|
|
8
|
+
HrLite::RoleSeeds.call
|
|
9
|
+
super_admin = HrLite::Role.find_by(name: "Super Admin") or return
|
|
10
|
+
HrLite::RoleGrant.find_or_initialize_by(role: super_admin, permission_key: "payroll.verify").update!(scope: "all")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def down; end
|
|
14
|
+
end
|
|
@@ -22,6 +22,9 @@ module HrLite
|
|
|
22
22
|
"attendance.missed_check_out" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
23
23
|
"attendance.short_day" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
24
24
|
"payroll.finalized" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
|
|
25
|
+
"ca.flagged" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
26
|
+
"ca.reminder" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
27
|
+
"ca.overdue" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
25
28
|
"payroll.approval_needed" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
26
29
|
"payroll.overdue" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
27
30
|
"payroll.published" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
data/lib/hr_lite/permissions.rb
CHANGED
|
@@ -37,6 +37,7 @@ module HrLite
|
|
|
37
37
|
"payroll.view" => [ "Payroll", "See payroll runs and salary slips" ],
|
|
38
38
|
"payroll.manage" => [ "Payroll", "Create, compute, finalize, unlock and publish payroll" ],
|
|
39
39
|
"payroll.export" => [ "Payroll", "Download the payout register, including bank details" ],
|
|
40
|
+
"payroll.verify" => [ "Payroll", "Verify or flag each salary slip and tax declaration at year end (CA review)" ],
|
|
40
41
|
"payroll.approve" => [ "Payroll", "Approve a computed payroll run (two approvals finalize and publish it)" ],
|
|
41
42
|
"salary.view" => [ "Payroll", "See salary structures" ],
|
|
42
43
|
"salary.manage" => [ "Payroll", "Set and revise salary structures" ],
|
data/lib/hr_lite/role_seeds.rb
CHANGED
|
@@ -70,6 +70,12 @@ module HrLite
|
|
|
70
70
|
"asset.view" => "all", "asset.manage" => "all", "checklist.manage" => "all"
|
|
71
71
|
}
|
|
72
72
|
},
|
|
73
|
+
Role::CA => {
|
|
74
|
+
description: "Outside chartered accountant: reads every slip and tax declaration and verifies them before 31 March.",
|
|
75
|
+
grants: {
|
|
76
|
+
"payroll.view" => "all", "salary.view" => "all", "tax.view" => "all", "payroll.verify" => "all"
|
|
77
|
+
}
|
|
78
|
+
},
|
|
73
79
|
Role::SUPER_ADMIN => {
|
|
74
80
|
description: "Everything, including pay, appraisals and who holds which role.",
|
|
75
81
|
grants: Permissions::KEYS.to_h { |key| [ key, "all" ] }
|
data/lib/hr_lite/version.rb
CHANGED
data/lib/hr_lite.rb
CHANGED
|
@@ -114,7 +114,15 @@ module HrLite
|
|
|
114
114
|
# Everyone HR tracks (host-overridable to exclude bots/test accounts),
|
|
115
115
|
# sorted by display name for team screens.
|
|
116
116
|
def employees
|
|
117
|
-
|
|
117
|
+
outside = outside_accountant_ids
|
|
118
|
+
config.employees_scope.call.reject { |u| outside.include?(u.id) }.sort_by { |u| display_name(u).downcase }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# CA logins with no HR profile: they review pay, they are not staff, so
|
|
122
|
+
# no attendance, leave, payroll or team list ever counts them.
|
|
123
|
+
def outside_accountant_ids
|
|
124
|
+
ids = HrLite::RoleAssignment.joins(:role).where(hr_lite_roles: { name: HrLite::Role::CA }).pluck(:user_id)
|
|
125
|
+
(ids - HrLite::EmployeeProfile.where(user_id: ids).pluck(:user_id)).to_set
|
|
118
126
|
end
|
|
119
127
|
|
|
120
128
|
# employees minus anyone whose profile says they have exited — user
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hr_lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.26.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -80,6 +80,7 @@ files:
|
|
|
80
80
|
- app/controllers/hr_lite/admin/awards_controller.rb
|
|
81
81
|
- app/controllers/hr_lite/admin/base_controller.rb
|
|
82
82
|
- app/controllers/hr_lite/admin/benefits_controller.rb
|
|
83
|
+
- app/controllers/hr_lite/admin/ca_reviews_controller.rb
|
|
83
84
|
- app/controllers/hr_lite/admin/checklists_controller.rb
|
|
84
85
|
- app/controllers/hr_lite/admin/comp_off_requests_controller.rb
|
|
85
86
|
- app/controllers/hr_lite/admin/designation_changes_controller.rb
|
|
@@ -143,6 +144,8 @@ files:
|
|
|
143
144
|
- app/jobs/hr_lite/application_job.rb
|
|
144
145
|
- app/jobs/hr_lite/approval_escalation_job.rb
|
|
145
146
|
- app/jobs/hr_lite/attendance_close_job.rb
|
|
147
|
+
- app/jobs/hr_lite/ca_review_overdue_job.rb
|
|
148
|
+
- app/jobs/hr_lite/ca_review_reminder_job.rb
|
|
146
149
|
- app/jobs/hr_lite/daily_digest_job.rb
|
|
147
150
|
- app/jobs/hr_lite/document_expiry_job.rb
|
|
148
151
|
- app/jobs/hr_lite/leave_year_rollover_job.rb
|
|
@@ -167,6 +170,7 @@ files:
|
|
|
167
170
|
- app/models/hr_lite/award.rb
|
|
168
171
|
- app/models/hr_lite/benefit.rb
|
|
169
172
|
- app/models/hr_lite/benefit_enrolment.rb
|
|
173
|
+
- app/models/hr_lite/ca_review.rb
|
|
170
174
|
- app/models/hr_lite/checklist_item.rb
|
|
171
175
|
- app/models/hr_lite/checklist_template.rb
|
|
172
176
|
- app/models/hr_lite/comp_off_request.rb
|
|
@@ -237,6 +241,7 @@ files:
|
|
|
237
241
|
- app/views/hr_lite/admin/awards/new.html.erb
|
|
238
242
|
- app/views/hr_lite/admin/benefits/index.html.erb
|
|
239
243
|
- app/views/hr_lite/admin/benefits/new.html.erb
|
|
244
|
+
- app/views/hr_lite/admin/ca_reviews/index.html.erb
|
|
240
245
|
- app/views/hr_lite/admin/checklists/index.html.erb
|
|
241
246
|
- app/views/hr_lite/admin/comp_off_requests/index.html.erb
|
|
242
247
|
- app/views/hr_lite/admin/comp_off_requests/show.html.erb
|
|
@@ -412,6 +417,8 @@ files:
|
|
|
412
417
|
- db/migrate/20261010100300_seed_hr_lite_fy2026_rate_cards.rb
|
|
413
418
|
- db/migrate/20261010100400_add_tax_fields_to_hr_lite_tax_declaration_items.rb
|
|
414
419
|
- db/migrate/20261010100500_grant_hr_lite_tax_to_hr.rb
|
|
420
|
+
- db/migrate/20261010110000_create_hr_lite_ca_reviews.rb
|
|
421
|
+
- db/migrate/20261010110100_seed_hr_lite_ca_role.rb
|
|
415
422
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
416
423
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
417
424
|
- lib/generators/hr_lite/install/templates/initializer.rb
|