hr_lite 0.23.1 → 0.25.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +2 -1
  4. data/app/assets/javascripts/hr_lite/tax_preview.js +38 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +12 -1
  6. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +14 -4
  7. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +6 -16
  8. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +46 -0
  9. data/app/controllers/hr_lite/tax_declarations_controller.rb +19 -1
  10. data/app/controllers/hr_lite/timelines_controller.rb +2 -1
  11. data/app/helpers/hr_lite/application_helper.rb +5 -0
  12. data/app/jobs/hr_lite/payroll_auto_approve_job.rb +21 -0
  13. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +1 -1
  14. data/app/models/hr_lite/payroll_run.rb +47 -3
  15. data/app/models/hr_lite/salary_structure.rb +22 -8
  16. data/app/models/hr_lite/statutory_rate_card_record.rb +6 -6
  17. data/app/models/hr_lite/tax_declaration.rb +24 -0
  18. data/app/models/hr_lite/tax_declaration_item.rb +56 -4
  19. data/app/services/hr_lite/calculators/tds.rb +7 -6
  20. data/app/services/hr_lite/payroll_run_processor.rb +9 -0
  21. data/app/services/hr_lite/slip_builder.rb +1 -1
  22. data/app/services/hr_lite/tax_computation.rb +61 -0
  23. data/app/services/hr_lite/timeline.rb +10 -1
  24. data/app/views/hr_lite/admin/employees/show.html.erb +3 -1
  25. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +17 -7
  26. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +15 -7
  27. data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +1 -1
  28. data/app/views/hr_lite/admin/tax_declarations/index.html.erb +1 -0
  29. data/app/views/hr_lite/admin/tax_declarations/overview.html.erb +39 -0
  30. data/app/views/hr_lite/admin/tax_declarations/person.html.erb +10 -0
  31. data/app/views/hr_lite/admin/tax_declarations/show.html.erb +19 -2
  32. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +6 -4
  33. data/app/views/hr_lite/tax/_computation.html.erb +56 -0
  34. data/app/views/hr_lite/tax_declarations/show.html.erb +61 -59
  35. data/app/views/hr_lite/timelines/show.html.erb +1 -0
  36. data/config/routes.rb +4 -2
  37. data/db/migrate/20261010100000_add_ctc_to_hr_lite_salary_structures.rb +9 -0
  38. data/db/migrate/20261010100100_add_approval_to_hr_lite_payroll_runs.rb +10 -0
  39. data/db/migrate/20261010100200_grant_hr_lite_payroll_approve.rb +22 -0
  40. data/db/migrate/20261010100300_seed_hr_lite_fy2026_rate_cards.rb +16 -0
  41. data/db/migrate/20261010100400_add_tax_fields_to_hr_lite_tax_declaration_items.rb +9 -0
  42. data/db/migrate/20261010100500_grant_hr_lite_tax_to_hr.rb +12 -0
  43. data/lib/hr_lite/notifications.rb +2 -0
  44. data/lib/hr_lite/permissions.rb +1 -0
  45. data/lib/hr_lite/role_seeds.rb +3 -3
  46. data/lib/hr_lite/statutory_rate_card.rb +65 -43
  47. data/lib/hr_lite/statutory_seeds.rb +4 -2
  48. data/lib/hr_lite/version.rb +1 -1
  49. metadata +13 -1
@@ -4,22 +4,55 @@ module HrLite
4
4
  # `verified_amount` is what the proof actually supported, which is often
5
5
  # less than what was claimed and is the number payroll uses once HR has
6
6
  # looked. Both are encrypted: what somebody invests is their business.
7
+ # The legal limit is applied on top, so nobody's arithmetic decides it.
7
8
  class TaxDeclarationItem < ApplicationRecord
8
9
  include EncryptedMoney
9
10
 
10
- SECTIONS = %w[80c 80d 80ccd_1b 24b hra other].freeze
11
+ SECTIONS = %w[80c 80ccd_1b 80d 80d_parents 24b hra other].freeze
12
+
13
+ # Annual limits (old regime). 80D doubles for a senior; HRA is computed
14
+ # from rent; "other" has no single limit.
15
+ CAPS = { "80c" => 150_000, "80ccd_1b" => 50_000, "24b" => 200_000,
16
+ "80d" => 25_000, "80d_parents" => 25_000 }.freeze
17
+ SENIOR_80D_CAP = 50_000
18
+ RENT_NEEDS_LANDLORD_PAN = 100_000
19
+ PROOF_TYPES = %w[application/pdf image/jpeg image/png].freeze
20
+ PROOF_MAX_BYTES = 5.megabytes
11
21
 
12
22
  encrypted_money :declared_amount, :verified_amount
23
+ encrypts :landlord_pan
24
+ normalizes :landlord_pan, with: ->(pan) { pan.to_s.strip.upcase.presence }
13
25
 
14
26
  belongs_to :declaration, class_name: "HrLite::TaxDeclaration"
27
+ has_many_attached :proofs
15
28
 
16
29
  validates :section, inclusion: { in: SECTIONS }
17
30
  validate :amounts_are_not_negative
31
+ validate :landlord_pan_for_large_rent
32
+ validate :proofs_are_documents
18
33
 
34
+ # Labels name the Income-tax Act 2025 section with the 1961 one people know.
19
35
  def section_label
20
- { "80c" => "80C — investments", "80d" => "80D — health insurance",
21
- "80ccd_1b" => "80CCD(1B) — NPS", "24b" => "24(b) — home loan interest",
22
- "hra" => "HRA exemption", "other" => "Other" }.fetch(section, section)
36
+ { "80c" => "Sec 123 (old 80C) — PF, PPF, ELSS, LIC, tuition fees, home-loan principal",
37
+ "80ccd_1b" => "Sec 124 (old 80CCD(1B)) — your own extra NPS",
38
+ "80d" => "Sec 126 (old 80D) — health insurance for you and family",
39
+ "80d_parents" => "Sec 126 (old 80D) — health insurance for parents",
40
+ "24b" => "Home-loan interest (old 24(b))",
41
+ "hra" => "Rent paid in the year (for the HRA exemption)",
42
+ "other" => "Other deductions" }.fetch(section, section)
43
+ end
44
+
45
+ def cap
46
+ return SENIOR_80D_CAP if senior && section.start_with?("80d")
47
+
48
+ CAPS[section]
49
+ end
50
+
51
+ # What payroll counts for this line: the claim until HR has checked the
52
+ # proof, then what the proof supported — never above the legal limit.
53
+ def allowed(verified:)
54
+ base = verified ? Money.d(verified_amount) : Money.d(declared_amount)
55
+ cap ? [ base, BigDecimal(cap) ].min : base
23
56
  end
24
57
 
25
58
  private
@@ -28,5 +61,24 @@ module HrLite
28
61
  errors.add(:declared_amount, "cannot be negative") if declared_amount&.negative?
29
62
  errors.add(:verified_amount, "cannot be negative") if verified_amount&.negative?
30
63
  end
64
+
65
+ def landlord_pan_for_large_rent
66
+ return unless section == "hra" && Money.d(declared_amount) > RENT_NEEDS_LANDLORD_PAN
67
+
68
+ if landlord_pan.blank?
69
+ errors.add(:landlord_pan, "is needed when rent is above ₹1,00,000 a year")
70
+ elsif !landlord_pan.match?(/\A[A-Z]{5}[0-9]{4}[A-Z]\z/)
71
+ errors.add(:landlord_pan, "is not a valid PAN (like ABCDE1234F)")
72
+ end
73
+ end
74
+
75
+ def proofs_are_documents
76
+ proofs.each do |proof|
77
+ next if PROOF_TYPES.include?(proof.blob.content_type) && proof.blob.byte_size <= PROOF_MAX_BYTES
78
+
79
+ errors.add(:proofs, "must be PDF, JPG or PNG files of up to 5 MB")
80
+ break
81
+ end
82
+ end
31
83
  end
32
84
  end
@@ -67,15 +67,16 @@ module HrLite
67
67
  )
68
68
  end
69
69
 
70
- def self.slab_tax_for(taxable, slabs)
71
- slabs.sum(BigDecimal(0)) do |lower, upper, rate|
72
- next BigDecimal(0) if taxable <= lower
70
+ def self.slab_tax_for(taxable, slabs) = slab_rows(taxable, slabs).sum(BigDecimal(0)) { |row| row[:tax] }
71
+ private_class_method :slab_tax_for
73
72
 
74
- span_top = upper && taxable > upper ? upper : taxable
75
- (span_top - lower) * rate
73
+ # Each slab with the income that falls in it and the tax on that part.
74
+ def self.slab_rows(taxable, slabs)
75
+ slabs.map do |lower, upper, rate|
76
+ span = taxable <= lower ? BigDecimal(0) : (upper && taxable > upper ? upper : taxable) - lower
77
+ { from: lower, to: upper, rate: rate, income: span, tax: span * rate }
76
78
  end
77
79
  end
78
- private_class_method :slab_tax_for
79
80
  end
80
81
  end
81
82
  end
@@ -61,6 +61,15 @@ module HrLite
61
61
  "it genuinely levies no PT."
62
62
  end
63
63
 
64
+ # Only approved leave and fixes reach DayStatus; one still waiting is paid as loss of pay.
65
+ month = @run.period_month.all_month
66
+ pending = LeaveRequest.pending.overlapping_range(month.begin, month.end).count +
67
+ RegularizationRequest.pending.where(date: month).count
68
+ if pending.positive?
69
+ warnings << "#{pending} leave or attendance #{pending == 1 ? 'request is' : 'requests are'} still pending for " \
70
+ "#{@run.label} — they count as loss of pay until approved; approve or reject, then recompute."
71
+ end
72
+
64
73
  @run.salary_slips.where.not(id: keep_ids).destroy_all
65
74
  @run.update!(warnings: warnings)
66
75
  end
@@ -138,7 +138,7 @@ module HrLite
138
138
  # A rejected declaration lowers nothing: its self-declared total stays editable and unchecked.
139
139
  return @profile.declared_annual_deductions if declaration.draft? || declaration.rejected?
140
140
 
141
- declaration.allowable_total
141
+ declaration.old_regime_deductions(structure: @structure)
142
142
  end
143
143
 
144
144
  # Splits this month's one-off lines into earnings and deductions, in the
@@ -0,0 +1,61 @@
1
+ module HrLite
2
+ # Somebody's income tax for the financial year, worked out now from what
3
+ # is true now: the salary structure in force, the slips already paid, and
4
+ # their declaration with the legal limits applied. Both regimes, with every
5
+ # step shown, so "why is my TDS X" answers itself. Uses the same TDS
6
+ # calculator payroll uses, so the screen and the slip never disagree.
7
+ class TaxComputation
8
+ Sheet = Struct.new(:regime, :gross, :hra_exemption, :standard_deduction, :deductions, :taxable,
9
+ :slabs, :annual_tax, :tds_paid, :remaining, :months_left, :monthly, keyword_init: true)
10
+
11
+ attr_reader :user, :month, :structure, :declaration, :paid
12
+
13
+ def initialize(user:, on: Date.current, declaration: nil)
14
+ @user = user
15
+ @month = on.beginning_of_month
16
+ @profile = EmployeeProfile.find_by(user_id: user.id)
17
+ @structure = SalaryStructure.effective_for(user, @month)
18
+ @declaration = declaration || TaxDeclaration.for(user, @month)
19
+ @paid = SalarySlip.fy_to_date(user, @month)
20
+ end
21
+
22
+ def regime = declaration&.regime || @profile&.tax_regime || "new"
23
+
24
+ def months_left = month.month >= 4 ? 16 - month.month : 4 - month.month
25
+
26
+ def paid_gross = paid[:gross] + Money.d(@profile&.fy_opening_gross)
27
+
28
+ def projected_gross = Money.d(structure&.monthly_gross) * months_left
29
+
30
+ def sheets = @sheets ||= TaxDeclaration::REGIMES.to_h { |name| [ name, sheet(name) ] }
31
+
32
+ def chosen = sheets[regime]
33
+
34
+ # The regime that costs less this year, and by how much.
35
+ def saving
36
+ new_tax, old_tax = sheets.values_at("new", "old").map(&:annual_tax)
37
+ new_tax <= old_tax ? [ "new", old_tax - new_tax ] : [ "old", new_tax - old_tax ]
38
+ end
39
+
40
+ private
41
+
42
+ def sheet(name)
43
+ rates = StatutoryRateCard.for(month)[:income_tax]
44
+ table = rates[name]
45
+ rows = name == "old" && declaration && structure ? declaration.deduction_rows(structure: structure) : []
46
+ hra = rows.find { |row| row.section == "hra" }&.allowed || BigDecimal(0)
47
+ tds_paid = paid[:tds] + Money.d(@profile&.fy_opening_tds)
48
+ result = Calculators::Tds.call(
49
+ regime: name, structure_monthly_gross: Money.d(structure&.monthly_gross),
50
+ gross_earned_this_month: Money.d(structure&.monthly_gross), fy_gross_paid: paid_gross,
51
+ fy_tds_paid: tds_paid, months_remaining: months_left,
52
+ declared_annual_deductions: rows.sum(BigDecimal(0), &:allowed), rates: rates
53
+ )
54
+ Sheet.new(regime: name, gross: result.projected_annual_gross, hra_exemption: hra,
55
+ standard_deduction: table[:standard_deduction], deductions: rows.reject { |row| row.section == "hra" },
56
+ taxable: result.taxable, slabs: Calculators::Tds.slab_rows(result.taxable, table[:slabs]),
57
+ annual_tax: result.annual_tax, tds_paid: tds_paid,
58
+ remaining: [ result.annual_tax - tds_paid, BigDecimal(0) ].max, months_left: months_left, monthly: result.monthly)
59
+ end
60
+ end
61
+ end
@@ -18,7 +18,7 @@ module HrLite
18
18
 
19
19
  def private_events
20
20
  newest_first(pay_items + loans + expenses + appraisals + plans + attendance_fixes + comp_offs +
21
- assets + documents + resignations + probation + hr_requests)
21
+ assets + documents + resignations + probation + hr_requests + open_leave)
22
22
  end
23
23
 
24
24
  private
@@ -124,6 +124,15 @@ module HrLite
124
124
  end
125
125
  end
126
126
 
127
+ # Leave that never happened: still waiting, or turned down.
128
+ def open_leave
129
+ return [] unless visible?("leave.view")
130
+
131
+ LeaveRequest.where(**mine, status: %w[pending rejected]).overlapping_range(@range.begin, @range.end).includes(:leave_type).map do |leave|
132
+ event(leave.start_date, "Leave", "#{leave.leave_type.name} — #{leave.status}", leave.date_range_label)
133
+ end
134
+ end
135
+
127
136
  def attendance_fixes
128
137
  return [] unless visible?("attendance.view")
129
138
 
@@ -9,6 +9,8 @@
9
9
  <a class="hrl-btn" href="<%= hr_lite.new_admin_employee_appraisal_path(@profile) %>">New appraisal</a>
10
10
  <a class="hrl-btn" href="<%= hr_lite.new_admin_employee_designation_change_path(@profile) %>">Role change</a>
11
11
  <a class="hrl-btn" href="<%= hr_lite.new_admin_performance_plan_path(user_id: @profile.user_id) %>">Start PIP</a>
12
+ <% end %>
13
+ <% if hr_can?("salary.manage", scope: :all) %>
12
14
  <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_employee_salary_structure_path(@profile) %>">New structure</a>
13
15
  <% end %>
14
16
  </div>
@@ -84,7 +86,7 @@
84
86
  </dl>
85
87
  </section>
86
88
 
87
- <% if hr_superadmin? %>
89
+ <% if hr_can?("salary.view", scope: :all) %>
88
90
  <section class="hrl-card">
89
91
  <h2 class="hrl-card__title">Salary structures</h2>
90
92
  <% if @structures.any? %>
@@ -26,28 +26,38 @@
26
26
  <span class="hrl-badge"><%= @run.status.humanize %></span>
27
27
  <%# `processing` is offered too: a run stranded there by a killed process
28
28
  would otherwise have no control at all and block that month forever. %>
29
- <% if @run.draft? || @run.review? || @run.processing? %>
29
+ <% manager = hr_can?("payroll.manage", scope: :all) %>
30
+ <% if manager && (@run.draft? || @run.review? || @run.processing?) %>
30
31
  <%= button_to @run.draft? ? "Compute" : "Recompute", hr_lite.compute_admin_payroll_run_path(@run),
31
- method: :post, class: "hrl-btn hrl-btn--primary" %>
32
+ method: :post, class: "hrl-btn" %>
32
33
  <% end %>
33
- <% if @run.review? %>
34
- <%= button_to "Finalize", hr_lite.finalize_admin_payroll_run_path(@run), method: :post, class: "hrl-btn" %>
34
+ <% if @run.review? && hr_can?("payroll.approve", scope: :all) && @run.first_approved_by_id != hr_current_user.id %>
35
+ <%= button_to @run.first_approved_by_id ? "Approve and publish" : "Approve", hr_lite.approve_admin_payroll_run_path(@run),
36
+ method: :post, class: "hrl-btn hrl-btn--primary",
37
+ form: { data: { turbo_confirm: @run.first_approved_by_id ? "Approve #{@run.label}? This finalizes the run and publishes the slips." : "Approve #{@run.label}?" } } %>
35
38
  <% end %>
36
- <% if @run.finalized? %>
39
+ <% if manager && @run.finalized? %>
37
40
  <%= button_to "Unlock", hr_lite.unlock_admin_payroll_run_path(@run), method: :post, class: "hrl-btn" %>
38
41
  <%= button_to "Publish to employees", hr_lite.publish_admin_payroll_run_path(@run), method: :post,
39
42
  class: "hrl-btn hrl-btn--primary",
40
43
  form: { data: { turbo_confirm: "Publish #{@run.label}? Employees will be notified." } } %>
41
44
  <% end %>
42
- <% if @slips.any? %>
45
+ <% if @slips.any? && hr_can?("payroll.export", scope: :all) %>
43
46
  <a class="hrl-btn" href="<%= hr_lite.register_admin_payroll_run_path(@run, format: :csv) %>">Register CSV</a>
44
47
  <% end %>
45
- <% if @run.draft? %>
48
+ <% if manager && @run.draft? %>
46
49
  <%= button_to "Delete draft", hr_lite.admin_payroll_run_path(@run), method: :delete,
47
50
  class: "hrl-btn hrl-btn--danger",
48
51
  form: { data: { turbo_confirm: "Delete this draft run?" } } %>
49
52
  <% end %>
50
53
  </div>
54
+ <p class="hrl-small hrl-muted hrl-mt">
55
+ <% if @run.review? %>
56
+ <%= @run.first_approved_by ? "Approved by #{hr_display_name(@run.first_approved_by)} on #{@run.first_approved_at.strftime('%-d %b')} — one more approver (HR or Super Admin) publishes it." : "Needs two approvals (HR and Super Admin). If nobody approves by the 3rd, a run with no blocking warnings is approved automatically." %>
57
+ <% elsif @run.auto_approved_at %>
58
+ Approved automatically on <%= @run.auto_approved_at.strftime("%-d %b") %> — nobody had approved it by the 3rd.
59
+ <% end %>
60
+ </p>
51
61
  </section>
52
62
 
53
63
  <section class="hrl-card">
@@ -2,15 +2,16 @@
2
2
  <%= form_with model: structure, url: url, method: method, scope: :salary_structure, local: true do |f| %>
3
3
  <%= render "hr_lite/shared/form_errors", record: structure %>
4
4
  <% breakup = structure.breakup(on: structure.effective_from || Date.current) if structure.basic %>
5
- <div class="hrl-field"><%= f.label :annual_ctc, "Annual CTC (₹ a year)" %><%= f.number_field :annual_ctc, step: 1, value: (structure.annual_ctc || breakup&.dig(:annual_ctc))&.round %></div>
5
+ <div class="hrl-field">
6
+ <%= f.label :annual_ctc, "Annual CTC (₹ a year)" %>
7
+ <%= f.number_field :annual_ctc, step: 1, min: 1, required: true, value: (structure.annual_ctc || breakup&.dig(:annual_ctc))&.round %>
8
+ <p class="hrl-hint">The only amount you type. Saving splits it: Basic 50%, HRA 50% of Basic in a metro (40% elsewhere), employer PF, ESI and gratuity (4.81% of Basic) inside the CTC, the rest as special allowance.</p>
9
+ </div>
6
10
  <div class="hrl-field">
7
11
  <%= f.label :effective_from, "Effective from (1st of month)" %>
8
12
  <%= f.date_field :effective_from %>
9
13
  </div>
10
- <div class="hrl-field"><%= f.label :basic, "Basic (monthly)" %><%= f.number_field :basic, step: 0.01, value: structure.basic&.to_s("F") %></div>
11
- <div class="hrl-field"><%= f.label :hra, "HRA" %><%= f.number_field :hra, step: 0.01, value: structure.hra&.to_s("F") %></div>
12
- <div class="hrl-field"><%= f.label :special_allowance %><%= f.number_field :special_allowance, step: 0.01, value: structure.special_allowance&.to_s("F") %></div>
13
- <div class="hrl-field"><%= f.label :other_earnings %><%= f.number_field :other_earnings, step: 0.01, value: structure.other_earnings&.to_s("F") %></div>
14
+ <div class="hrl-field hrl-field--check"><%= f.check_box :metro %><%= f.label :metro, "Works in a metro (Delhi, Mumbai, Kolkata, Chennai)" %></div>
14
15
  <div class="hrl-field hrl-field--check"><%= f.check_box :pf_applicable %><%= f.label :pf_applicable, "PF applicable" %></div>
15
16
  <div class="hrl-field hrl-field--check"><%= f.check_box :pf_on_full_basic %><%= f.label :pf_on_full_basic, "PF on full basic (no ₹15k ceiling)" %></div>
16
17
  <div class="hrl-field hrl-field--check"><%= f.check_box :esi_applicable %><%= f.label :esi_applicable, "ESI applicable (auto-exempt above gross ceiling)" %></div>
@@ -23,10 +24,17 @@
23
24
  <%= f.select :pt_state, pt_states.map { |s| [ s.humanize, s ] } %>
24
25
  </div>
25
26
  <div class="hrl-field"><%= f.label :notes %><%= f.text_area :notes %></div>
26
- <% if breakup %><p class="hrl-muted hrl-small">Monthly gross <%= hrl_money(breakup[:monthly_gross]) %> · CTC <%= hrl_money(breakup[:monthly_ctc]) %> a month</p><% end %>
27
+ <% if breakup %>
28
+ <h3 class="hrl-card__title">Monthly split</h3>
29
+ <dl class="hrl-deflist">
30
+ <% breakup[:earnings].each do |label, amount| %><dt><%= label %></dt><dd><%= hrl_money(amount) %></dd><% end %>
31
+ <dt><strong>Gross</strong></dt><dd><strong><%= hrl_money(breakup[:monthly_gross]) %></strong></dd>
32
+ <% breakup[:employer].each do |label, amount| %><dt>Employer <%= label %></dt><dd><%= hrl_money(amount) %></dd><% end %>
33
+ <dt><strong>CTC</strong></dt><dd><strong><%= hrl_money(breakup[:monthly_ctc]) %></strong> a month</dd>
34
+ </dl>
35
+ <% end %>
27
36
  <div class="hrl-form-actions">
28
37
  <%= f.submit "Save structure", class: "hrl-btn hrl-btn--primary" %>
29
- <%= f.submit "Fill from CTC", name: "fill_from_ctc", class: "hrl-btn" %>
30
38
  <a class="hrl-btn" href="<%= hr_lite.admin_employee_path(@profile) %>">Cancel</a>
31
39
  </div>
32
40
  <% end %>
@@ -12,7 +12,7 @@
12
12
  <%= render "hr_lite/shared/form_errors", record: card %>
13
13
 
14
14
  <div class="hrl-field">
15
- <%= f.label :effective_from, "Financial year starts (1 April)" %>
15
+ <%= f.label :effective_from, "Takes effect on (1 April for a new financial year, or the 1st of the month a mid-year change applies from)" %>
16
16
  <%= f.date_field :effective_from %>
17
17
  </div>
18
18
 
@@ -7,6 +7,7 @@
7
7
  <a class="hrl-btn <%= "hrl-btn--primary" if @status == status %>"
8
8
  href="<%= hr_lite.admin_tax_declarations_path(status: status) %>"><%= status.humanize %></a>
9
9
  <% end %>
10
+ <a class="hrl-btn" href="<%= hr_lite.overview_admin_tax_declarations_path %>">Everyone's tax</a>
10
11
  </div>
11
12
  </section>
12
13
 
@@ -0,0 +1,39 @@
1
+ <% content_for(:page_title) { "Everyone's tax" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Everyone's tax — <%= HrLite::FinancialYear.label(Date.current) %></h1>
4
+ <div class="hrl-page__actions"><a class="hrl-btn" href="<%= hr_lite.admin_tax_declarations_path %>">Declarations</a></div>
5
+ </div>
6
+ <p class="hrl-small hrl-muted">Worked out now from each salary structure, the slips already paid and the declaration (limits applied). Open a declaration for the full working.</p>
7
+
8
+ <section class="hrl-card">
9
+ <div class="hrl-table-wrap">
10
+ <table class="hrl-table hrl-table--stack">
11
+ <thead><tr><th>Person</th><th>Regime</th><th>Tax for the year</th><th>Deducted so far</th><th>TDS a month</th><th>Declaration</th></tr></thead>
12
+ <tbody>
13
+ <% @profiles.each do |profile| %>
14
+ <% declaration = @declarations[profile.user_id] %>
15
+ <% tax = HrLite::TaxComputation.new(user: profile.user, declaration: declaration) %>
16
+ <tr>
17
+ <td data-label="Person"><a class="hrl-card__link" href="<%= hr_lite.person_admin_tax_declarations_path(profile.user_id) %>"><%= hr_display_name(profile.user) %></a> <span class="hrl-small hrl-muted"><%= profile.employee_code %></span></td>
18
+ <% if tax.structure %>
19
+ <td data-label="Regime"><%= tax.regime.humanize %><% winner, by = tax.saving %><% if winner != tax.regime && by.positive? %><br><span class="hrl-small hrl-muted"><%= winner.humanize %> saves <%= hrl_rupees(by) %></span><% end %></td>
20
+ <td data-label="Tax for the year" class="hrl-num"><%= hrl_rupees(tax.chosen.annual_tax) %></td>
21
+ <td data-label="Deducted so far" class="hrl-num"><%= hrl_rupees(tax.chosen.tds_paid) %></td>
22
+ <td data-label="TDS a month" class="hrl-num"><%= hrl_rupees(tax.chosen.monthly) %></td>
23
+ <% else %>
24
+ <td data-label="Regime" colspan="4" class="hrl-muted">No salary structure</td>
25
+ <% end %>
26
+ <td data-label="Declaration">
27
+ <% if declaration %>
28
+ <a class="hrl-card__link" href="<%= hr_lite.admin_tax_declaration_path(declaration) %>"><%= declaration.status.humanize %></a>
29
+ <% else %>
30
+ <span class="hrl-muted">Not filed</span>
31
+ <% end %>
32
+ </td>
33
+ </tr>
34
+ <% end %>
35
+ </tbody>
36
+ </table>
37
+ </div>
38
+ <%= hrl_pagination %>
39
+ </section>
@@ -0,0 +1,10 @@
1
+ <% content_for(:page_title) { "Tax — #{hr_display_name(@person)}" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Tax — <%= hr_display_name(@person) %></h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn" href="<%= hr_lite.overview_admin_tax_declarations_path %>">Everyone's tax</a>
6
+ <% if @tax.declaration %><a class="hrl-btn" href="<%= hr_lite.admin_tax_declaration_path(@tax.declaration) %>">Declaration</a><% end %>
7
+ </div>
8
+ </div>
9
+ <% unless @tax.declaration %><p class="hrl-small hrl-muted">No declaration filed this year — the old-regime figure carries no investments yet.</p><% end %>
10
+ <%= render "hr_lite/tax/computation", tax: @tax, both: true %>
@@ -17,6 +17,8 @@
17
17
  </p>
18
18
  </section>
19
19
 
20
+ <%= render "hr_lite/tax/computation", tax: @tax, both: true %>
21
+
20
22
  <%= form_with model: @declaration, url: hr_lite.admin_tax_declaration_path(@declaration),
21
23
  method: :patch, scope: :tax_declaration, local: true do |f| %>
22
24
  <section class="hrl-card">
@@ -27,7 +29,18 @@
27
29
  <span class="hrl-small hrl-muted">
28
30
  Claimed <%= hrl_money(item.object.declared_amount) %>
29
31
  <%= "— #{item.object.label}" if item.object.label.present? %>
32
+ <%= "· limit #{hrl_money(item.object.cap)}" if item.object.cap %>
33
+ <%= "· landlord PAN #{item.object.landlord_pan}" if item.object.landlord_pan.present? %>
30
34
  </span>
35
+ <% if item.object.proofs.attached? %>
36
+ <span class="hrl-row">
37
+ <% item.object.proofs.each do |proof| %>
38
+ <a class="hrl-card__link" href="<%= hr_lite.proof_admin_tax_declaration_path(@declaration, proof_id: proof.id) %>">Open <%= proof.filename %></a>
39
+ <% end %>
40
+ </span>
41
+ <% else %>
42
+ <span class="hrl-small hrl-badge hrl-badge--warn">No proof attached</span>
43
+ <% end %>
31
44
  <%= item.text_field :verified_amount,
32
45
  value: item.object.verified_amount&.to_s("F"), inputmode: "decimal",
33
46
  placeholder: "Verified amount" %>
@@ -41,8 +54,12 @@
41
54
 
42
55
  <section class="hrl-card">
43
56
  <h2 class="hrl-card__title">Decide</h2>
44
- <%= button_to "Verify", hr_lite.verify_admin_tax_declaration_path(@declaration),
45
- method: :post, class: "hrl-btn hrl-btn--primary" %>
57
+ <div class="hrl-row">
58
+ <%= button_to "Accept all and verify", hr_lite.accept_all_admin_tax_declaration_path(@declaration),
59
+ method: :post, class: "hrl-btn hrl-btn--primary" %>
60
+ <%= button_to "Verify the amounts recorded above", hr_lite.verify_admin_tax_declaration_path(@declaration),
61
+ method: :post, class: "hrl-btn" %>
62
+ </div>
46
63
  <%= form_with url: hr_lite.reject_admin_tax_declaration_path(@declaration),
47
64
  method: :post, local: true do %>
48
65
  <div class="hrl-field">
@@ -1,10 +1,12 @@
1
- <section class="hrl-card">
2
- <h2 class="hrl-card__title">Leave balance <%= HrLite::LeaveYear.label(HrLite::LeaveYear.current_key) %></h2>
1
+ <%# locals: user (default: you), compact, bare (no card of its own — sits inside another card) %>
2
+ <% user = local_assigns[:user] || hr_current_user %>
3
+ <%= tag.send(local_assigns[:bare] ? :div : :section, class: ("hrl-card" unless local_assigns[:bare])) do %>
4
+ <%= tag.send(local_assigns[:bare] ? :h3 : :h2, class: "hrl-card__title") do %>Leave balance <%= HrLite::LeaveYear.label(HrLite::LeaveYear.current_key) %><% end %>
3
5
  <% types = HrLite::LeaveType.active.where(paid: true).where.not(annual_quota: nil) %>
4
6
  <% if types.any? %>
5
7
  <div class="hrl-row">
6
8
  <% types.each do |type| %>
7
- <% balance = HrLite::LeaveBalance.for(hr_current_user, type, HrLite::LeaveYear.current_key) %>
9
+ <% balance = HrLite::LeaveBalance.for(user, type, HrLite::LeaveYear.current_key) %>
8
10
  <span class="hrl-badge hrl-badge--type" style="--hrl-type: <%= type.color %>">
9
11
  <%= type.name %> · <%= hrl_days(balance.available) %> of <%= hrl_days(balance.entitled) %> left
10
12
  </span>
@@ -16,4 +18,4 @@
16
18
  <% else %>
17
19
  <p class="hrl-muted hrl-small">No leave types configured yet.</p>
18
20
  <% end %>
19
- </section>
21
+ <% end %>
@@ -0,0 +1,56 @@
1
+ <%# locals: tax (HrLite::TaxComputation), both (true on the admin sheet: every regime's working, open) %>
2
+ <% sheet = tax.chosen %>
3
+ <% winner, by = tax.saving %>
4
+ <% names = { "new" => "New regime", "old" => "Old regime" } %>
5
+ <section class="hrl-card" aria-live="polite">
6
+ <h2 class="hrl-card__title">Income tax <%= HrLite::FinancialYear.label(HrLite::FinancialYear.start_for(tax.month)) %></h2>
7
+ <% if tax.structure.nil? %>
8
+ <p class="hrl-muted">No salary structure yet — tax is worked out once one is set.</p>
9
+ <% else %>
10
+ <div class="hrl-kpis hrl-kpis--money">
11
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_rupees(sheet.annual_tax) %></div><div class="hrl-kpi__label">Tax for the year (<%= names[sheet.regime] %>)</div></div>
12
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_rupees(sheet.monthly) %></div><div class="hrl-kpi__label">TDS a month, next <%= pluralize(sheet.months_left, "month") %></div></div>
13
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_rupees(sheet.tds_paid) %></div><div class="hrl-kpi__label">Deducted so far</div></div>
14
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_rupees(sheet.remaining) %></div><div class="hrl-kpi__label">Still to deduct</div></div>
15
+ </div>
16
+
17
+ <div class="hrl-tax-compare" role="group" aria-label="Old and new regime compared">
18
+ <% tax.sheets.each do |name, s| %>
19
+ <div class="hrl-tax-compare__col<%= " hrl-tax-compare__col--chosen" if name == sheet.regime %>">
20
+ <strong><%= names[name] %></strong><% if name == sheet.regime %> <span class="hrl-badge">Chosen</span><% end %>
21
+ <div class="hrl-tax-compare__amount"><%= hrl_rupees(s.annual_tax) %></div>
22
+ <div class="hrl-small hrl-muted">a year · <%= hrl_rupees(s.monthly) %> a month</div>
23
+ </div>
24
+ <% end %>
25
+ </div>
26
+ <p class="hrl-small">
27
+ <% if by.zero? %>Both regimes cost the same this year.
28
+ <% else %><strong><%= names[winner] %></strong> costs <%= hrl_rupees(by) %> less this year<%= winner == sheet.regime ? " — you are on it." : "." %><% end %>
29
+ </p>
30
+
31
+ <% (both ? tax.sheets.values : [ sheet ]).each do |s| %>
32
+ <details class="hrl-tax-working" <%= "open" if both %>>
33
+ <summary class="hrl-btn">How the <%= names[s.regime].downcase %> tax is worked out</summary>
34
+ <table class="hrl-table hrl-tax-working__table">
35
+ <tbody>
36
+ <tr><td>Salary paid so far this year</td><td class="hrl-num"><%= hrl_rupees(tax.paid_gross) %></td></tr>
37
+ <tr><td>Expected for the rest of the year (<%= hrl_rupees(tax.structure.monthly_gross) %> × <%= s.months_left %>)</td><td class="hrl-num"><%= hrl_rupees(tax.projected_gross) %></td></tr>
38
+ <tr class="hrl-tax-working__total"><td>Gross salary for the year</td><td class="hrl-num"><%= hrl_rupees(s.gross) %></td></tr>
39
+ <% if s.hra_exemption.positive? %><tr><td>Less HRA exemption (from rent)</td><td class="hrl-num">− <%= hrl_rupees(s.hra_exemption) %></td></tr><% end %>
40
+ <tr><td>Less standard deduction</td><td class="hrl-num">− <%= hrl_rupees(s.standard_deduction) %></td></tr>
41
+ <% s.deductions.each do |row| %>
42
+ <tr><td>Less <%= row.label %><br><span class="hrl-small hrl-muted">claimed <%= hrl_rupees(row.claimed) %><%= " · limit #{hrl_rupees(row.cap)}" if row.cap %></span></td><td class="hrl-num">− <%= hrl_rupees(row.allowed) %></td></tr>
43
+ <% end %>
44
+ <tr class="hrl-tax-working__total"><td>Taxable income</td><td class="hrl-num"><%= hrl_rupees(s.taxable) %></td></tr>
45
+ <% s.slabs.select { |row| row[:income].positive? }.each do |row| %>
46
+ <tr><td><%= hrl_rupees(row[:from]) %> – <%= row[:to] ? hrl_rupees(row[:to]) : "above" %> at <%= (row[:rate] * 100).to_i %>% on <%= hrl_rupees(row[:income]) %></td><td class="hrl-num"><%= hrl_rupees(row[:tax]) %></td></tr>
47
+ <% end %>
48
+ <tr class="hrl-tax-working__total"><td>Tax for the year, after any §87A rebate, with 4% cess</td><td class="hrl-num"><%= hrl_rupees(s.annual_tax) %></td></tr>
49
+ <tr><td>Already deducted (TDS)</td><td class="hrl-num">− <%= hrl_rupees(s.tds_paid) %></td></tr>
50
+ <tr class="hrl-tax-working__total"><td>Each remaining month (<%= s.months_left %>)</td><td class="hrl-num"><%= hrl_rupees(s.monthly) %></td></tr>
51
+ </tbody>
52
+ </table>
53
+ </details>
54
+ <% end %>
55
+ <% end %>
56
+ </section>