hr_lite 0.14.0 → 0.16.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -1
  3. data/README.md +4 -1
  4. data/app/assets/javascripts/hr_lite/print.js +5 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +72 -0
  6. data/app/controllers/hr_lite/admin/documents_controller.rb +63 -0
  7. data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
  8. data/app/controllers/hr_lite/admin/holidays_controller.rb +7 -0
  9. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +23 -0
  10. data/app/controllers/hr_lite/admin/loans_controller.rb +59 -0
  11. data/app/controllers/hr_lite/admin/policies_controller.rb +29 -0
  12. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +75 -0
  13. data/app/controllers/hr_lite/documents_controller.rb +69 -0
  14. data/app/controllers/hr_lite/hr_requests_controller.rb +2 -0
  15. data/app/controllers/hr_lite/id_cards_controller.rb +25 -0
  16. data/app/controllers/hr_lite/loans_controller.rb +11 -0
  17. data/app/controllers/hr_lite/tax_declarations_controller.rb +100 -0
  18. data/app/helpers/hr_lite/application_helper.rb +18 -3
  19. data/app/models/hr_lite/approval_flow.rb +1 -1
  20. data/app/models/hr_lite/employee_profile.rb +21 -3
  21. data/app/models/hr_lite/hr_request.rb +12 -3
  22. data/app/models/hr_lite/leave_request.rb +15 -4
  23. data/app/models/hr_lite/policy.rb +15 -0
  24. data/app/models/hr_lite/setting.rb +1 -1
  25. data/app/models/hr_lite/tax_declaration.rb +4 -1
  26. data/app/views/hr_lite/admin/documents/index.html.erb +89 -0
  27. data/app/views/hr_lite/admin/documents/show.html.erb +42 -0
  28. data/app/views/hr_lite/admin/employees/_form.html.erb +5 -0
  29. data/app/views/hr_lite/admin/employees/show.html.erb +1 -0
  30. data/app/views/hr_lite/admin/hr_requests/show.html.erb +3 -0
  31. data/app/views/hr_lite/admin/leave_requests/index.html.erb +3 -0
  32. data/app/views/hr_lite/admin/leave_requests/new.html.erb +35 -0
  33. data/app/views/hr_lite/admin/loans/index.html.erb +43 -0
  34. data/app/views/hr_lite/admin/loans/new.html.erb +39 -0
  35. data/app/views/hr_lite/admin/loans/show.html.erb +52 -0
  36. data/app/views/hr_lite/admin/policies/index.html.erb +27 -0
  37. data/app/views/hr_lite/admin/policies/new.html.erb +22 -0
  38. data/app/views/hr_lite/admin/tax_declarations/index.html.erb +37 -0
  39. data/app/views/hr_lite/admin/tax_declarations/show.html.erb +54 -0
  40. data/app/views/hr_lite/documents/index.html.erb +84 -0
  41. data/app/views/hr_lite/id_cards/show.html.erb +86 -0
  42. data/app/views/hr_lite/loans/index.html.erb +52 -0
  43. data/app/views/hr_lite/shared/_bell.html.erb +3 -0
  44. data/app/views/hr_lite/tax_declarations/show.html.erb +68 -0
  45. data/app/views/layouts/hr_lite/application.html.erb +2 -0
  46. data/config/routes.rb +21 -1
  47. data/db/migrate/20260927100145_add_id_card_fields_to_hr_lite_employee_profiles.rb +6 -0
  48. data/db/migrate/20260927101031_add_created_by_id_to_hr_lite_leave_requests.rb +5 -0
  49. data/lib/hr_lite/configuration.rb +4 -1
  50. data/lib/hr_lite/notifications.rb +4 -3
  51. data/lib/hr_lite/version.rb +1 -1
  52. data/lib/hr_lite.rb +1 -0
  53. metadata +41 -1
@@ -0,0 +1,100 @@
1
+ module HrLite
2
+ # The employee's own declaration for a financial year. Replaces an admin
3
+ # typing one lump sum into the profile: the whole year's TDS is projected
4
+ # from this, so the person whose money it is fills it in.
5
+ class TaxDeclarationsController < ApplicationController
6
+ def show
7
+ @declaration = current_declaration || build_declaration
8
+ @regime = @declaration.regime
9
+ end
10
+
11
+ def update
12
+ # A bare record, not the form's scaffold: `build_declaration` fills in
13
+ # one blank line per section so the form is a list rather than an empty
14
+ # box, and saving those blanks fails a NOT NULL amount.
15
+ @declaration = current_declaration ||
16
+ TaxDeclaration.new(user_id: hr_current_user.id,
17
+ financial_year: financial_year, regime: employee_regime)
18
+ # A verified declaration is what payroll is deducting against; changing
19
+ # it would silently re-open a decision HR has already made.
20
+ if @declaration.verified?
21
+ return redirect_to tax_declaration_path,
22
+ alert: "This year's declaration is verified. Ask HR to reopen it."
23
+ end
24
+
25
+ if @declaration.update(declaration_params)
26
+ redirect_to tax_declaration_path, notice: "Saved."
27
+ else
28
+ # Re-render needs the full set of lines back, or the form loses the
29
+ # sections the person had not filled in yet.
30
+ fill_missing_sections(@declaration)
31
+ render :show, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ def submit
36
+ @declaration = current_declaration
37
+ return redirect_to tax_declaration_path, alert: "Nothing to submit yet." if @declaration.nil?
38
+
39
+ @declaration.submit!(actor: hr_current_user)
40
+ redirect_to tax_declaration_path, notice: "Submitted — HR will check the proof."
41
+ rescue ActiveRecord::RecordInvalid
42
+ redirect_to tax_declaration_path, alert: "Only a draft can be submitted."
43
+ end
44
+
45
+ private
46
+
47
+ def financial_year
48
+ FinancialYear.start_for(params[:financial_year].present? ? Date.parse(params[:financial_year]) : Date.current)
49
+ rescue Date::Error
50
+ FinancialYear.start_for(Date.current)
51
+ end
52
+
53
+ def current_declaration
54
+ TaxDeclaration.find_by(user_id: hr_current_user.id, financial_year: financial_year)
55
+ end
56
+
57
+ def build_declaration
58
+ declaration = TaxDeclaration.new(user_id: hr_current_user.id, financial_year: financial_year,
59
+ regime: employee_regime)
60
+ # One blank line per section, so the form is a list of the things a
61
+ # person can actually claim rather than an empty box and a plus button.
62
+ fill_missing_sections(declaration)
63
+ declaration
64
+ end
65
+
66
+ # Every section gets a line, whether or not it has been claimed yet.
67
+ def fill_missing_sections(declaration)
68
+ present = declaration.tax_declaration_items.map(&:section)
69
+ (TaxDeclarationItem::SECTIONS - present).each do |section|
70
+ declaration.tax_declaration_items.build(section: section)
71
+ end
72
+ end
73
+
74
+ def employee_regime
75
+ EmployeeProfile.find_by(user_id: hr_current_user.id)&.tax_regime.presence || "new"
76
+ end
77
+
78
+ # The form posts a line for every section, because that is what a list of
79
+ # claimable things looks like. Most of them are blank — a section with no
80
+ # amount is not a claim, and saving it violates a NOT NULL amount.
81
+ # An EXISTING line blanked out is a claim being withdrawn, so it is
82
+ # destroyed rather than skipped.
83
+ def declaration_params
84
+ permitted = params.require(:tax_declaration).permit(
85
+ :regime,
86
+ tax_declaration_items_attributes: %i[id section label declared_amount _destroy]
87
+ )
88
+ items = permitted[:tax_declaration_items_attributes]
89
+ return permitted if items.blank?
90
+
91
+ permitted[:tax_declaration_items_attributes] = items.to_h.filter_map { |key, row|
92
+ next [ key, row.merge("_destroy" => "1") ] if row["declared_amount"].blank? && row["id"].present?
93
+ next if row["declared_amount"].blank?
94
+
95
+ [ key, row ]
96
+ }.to_h
97
+ permitted
98
+ end
99
+ end
100
+ end
@@ -19,7 +19,11 @@ module HrLite
19
19
  { label: "Org", path: :org_path, match: [ "/org" ] },
20
20
  { label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
21
21
  { label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
22
- { label: "Career", path: :career_path, match: [ "/career", "/appraisals", "/profile" ] }
22
+ { label: "Career", path: :career_path, match: [ "/career", "/appraisals", "/profile" ] },
23
+ { label: "Documents", path: :documents_path, match: [ "/documents" ] },
24
+ { label: "ID card", path: :id_card_path, match: [ "/id_card" ] },
25
+ { label: "Tax", path: :tax_declaration_path, match: [ "/tax_declaration" ] },
26
+ { label: "Loans", path: :loans_path, match: [ "/loans" ] }
23
27
  ].freeze
24
28
 
25
29
  ADMIN_NAV_ITEMS = [
@@ -36,7 +40,9 @@ module HrLite
36
40
  LEADERSHIP_NAV_ITEMS = [
37
41
  { label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
38
42
  { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting" ] },
39
- { label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] }
43
+ { label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] },
44
+ { label: "Policies", path: :admin_policies_path, match: [ "/admin/policies" ] },
45
+ { label: "Documents", path: :admin_documents_path, match: [ "/admin/documents" ] }
40
46
  ].freeze
41
47
 
42
48
  # Money screens, plus role management: granting somebody payroll is the
@@ -45,9 +51,18 @@ module HrLite
45
51
  SUPERADMIN_NAV_ITEMS = [
46
52
  { label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] },
47
53
  { label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ] },
48
- { label: "Rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ] }
54
+ { label: "Rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ] },
55
+ { label: "Tax", path: :admin_tax_declarations_path, match: [ "/admin/tax_declarations" ] },
56
+ { label: "Loans", path: :admin_loans_path, match: [ "/admin/loans" ] }
49
57
  ].freeze
50
58
 
59
+ # The ID card's QR: office address and phone as plain text, so any phone
60
+ # camera shows who to call about a found card. No URL, nothing to host.
61
+ def hrl_office_qr_svg(company, brand)
62
+ text = [ brand, company[:address], company[:phone] ].compact_blank.join("\n")
63
+ RQRCode::QRCode.new(text).as_svg(use_path: true, viewbox: true, color: "0F172A").html_safe
64
+ end
65
+
51
66
  # Only items whose routes exist yet (the nav grows with each phase).
52
67
  def hrl_nav_items(items)
53
68
  items.select { |item| hr_lite_route?(item[:path]) }
@@ -4,7 +4,7 @@ module HrLite
4
4
  class ApprovalFlow < ApplicationRecord
5
5
  include Audited
6
6
 
7
- has_many :approval_steps, -> { order(:position) },
7
+ has_many :approval_steps, -> { order(:position) }, inverse_of: :flow,
8
8
  class_name: "HrLite::ApprovalStep", foreign_key: :flow_id, dependent: :destroy
9
9
  accepts_nested_attributes_for :approval_steps, allow_destroy: true
10
10
 
@@ -13,7 +13,7 @@ module HrLite
13
13
  # profiles as roots (founders/directors).
14
14
  belongs_to :manager, class_name: HrLite.config.user_class, optional: true
15
15
 
16
- encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc
16
+ encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc, :blood_group, :emergency_contact
17
17
  # fy_opening_*: income already paid this financial year that this install
18
18
  # did not run — a previous employer, or the months before payroll was
19
19
  # switched on here. Feeds the TDS projection so a mid-year start does not
@@ -21,12 +21,21 @@ module HrLite
21
21
  encrypted_money :declared_annual_deductions, :fy_opening_gross, :fy_opening_tds
22
22
 
23
23
  TAX_REGIMES = %w[new old].freeze
24
+ BLOOD_GROUPS = %w[A+ A- B+ B- AB+ AB- O+ O-].freeze
25
+ # The ID-card photo. Images a browser can print; content-sniffed like
26
+ # Document, so the check is on the real bytes.
27
+ PHOTO_TYPES = %w[image/jpeg image/png image/webp].freeze
28
+ PHOTO_MAX_BYTES = 5.megabytes
29
+
30
+ has_one_attached :photo
24
31
 
25
32
  # Onboarding-form virtuals (the controller hands them to
26
33
  # config.onboard_user; never persisted, never audited).
27
34
  attr_accessor :new_user_name, :new_user_email, :new_user_password
28
35
 
29
36
  before_validation :assign_employee_code, on: :create
37
+ # Without a role a new hire cannot see policies or raise an HR request.
38
+ after_create { (role = Role.find_by(name: Role::EMPLOYEE)) && RoleAssignment.find_or_create_by!(user_id:, role:) }
30
39
 
31
40
  validates :employee_code, presence: true, uniqueness: true
32
41
  validates :user_id, uniqueness: true
@@ -37,6 +46,8 @@ module HrLite
37
46
  validates :bank_ifsc, format: { with: /\A[A-Z]{4}0[A-Z0-9]{6}\z/, message: "is not a valid IFSC" },
38
47
  allow_blank: true
39
48
  validates :pf_uan, format: { with: /\A\d{12}\z/, message: "must be 12 digits" }, allow_blank: true
49
+ validates :blood_group, inclusion: { in: BLOOD_GROUPS }, allow_blank: true
50
+ validate :photo_is_a_small_image
40
51
  validate :exit_after_joining
41
52
  validate :manager_chain_acyclic
42
53
  validate :manager_is_active_staff, if: :manager_id_changed?
@@ -97,7 +108,7 @@ module HrLite
97
108
  private
98
109
 
99
110
  # Codes are system-assigned: Settings prefix + zero-padded next number
100
- # (EMP001, EMP002, ...). Scans the highest existing suffix for the
111
+ # (ESA-000001, ESA-000002, ...). Scans the highest existing suffix for the
101
112
  # CURRENT prefix so changing the prefix restarts a fresh sequence
102
113
  # without colliding with history.
103
114
  def assign_employee_code
@@ -108,7 +119,7 @@ module HrLite
108
119
  .pluck(:employee_code)
109
120
  .filter_map { |code| code.delete_prefix(prefix)[/\A\d+\z/]&.to_i }
110
121
  .max || 0
111
- self.employee_code = format("%s%03d", prefix, last + 1)
122
+ self.employee_code = format("%s%06d", prefix, last + 1)
112
123
  end
113
124
 
114
125
  def sanitize_sql_like(value)
@@ -140,6 +151,13 @@ module HrLite
140
151
  end
141
152
  end
142
153
 
154
+ def photo_is_a_small_image
155
+ return unless photo.attached?
156
+ return errors.add(:photo, "must be a JPG, PNG or WebP image") unless PHOTO_TYPES.include?(photo.blob.content_type)
157
+
158
+ errors.add(:photo, "must be under #{PHOTO_MAX_BYTES / 1.megabyte} MB") if photo.blob.byte_size > PHOTO_MAX_BYTES
159
+ end
160
+
143
161
  def mask_middle(value)
144
162
  return nil if value.blank?
145
163
  return "•" * value.length if value.length <= 4
@@ -8,7 +8,7 @@ module HrLite
8
8
 
9
9
  CATEGORIES = %w[
10
10
  salary_certificate employment_certificate address_change bank_change
11
- document_request payroll_query tax_query insurance_query policy_query other
11
+ document_request payroll_query tax_query insurance_query policy_query id_card other
12
12
  ].freeze
13
13
 
14
14
  belongs_to :user, class_name: HrLite.config.user_class
@@ -17,6 +17,7 @@ module HrLite
17
17
  validates :subject, presence: true
18
18
  validates :category, inclusion: { in: CATEGORIES }
19
19
  validates :status, inclusion: { in: STATUSES }
20
+ validate :id_card_can_be_printed, on: :create, if: -> { category == "id_card" }
20
21
 
21
22
  scope :open_requests, -> { where(status: %w[open in_progress]) }
22
23
  scope :recent_first, -> { order(created_at: :desc) }
@@ -25,7 +26,7 @@ module HrLite
25
26
 
26
27
  after_create_commit :notify_desk
27
28
 
28
- def category_label = category.humanize
29
+ def category_label = category.humanize.sub(/\AId\b/, "ID")
29
30
 
30
31
  def assign!(actor:, assignee:)
31
32
  update!(assigned_to_id: assignee.id, status: "in_progress")
@@ -68,8 +69,16 @@ module HrLite
68
69
  Notifications.publish(
69
70
  "hr_request.raised",
70
71
  title: "#{HrLite.display_name(user)} asked: #{subject}",
71
- body: body.presence, path: "/admin/hr_requests/#{id}", bell_to: desk
72
+ body: body.presence, path: "/admin/hr_requests/#{id}", bell_to: desk, email_to: desk
72
73
  )
73
74
  end
75
+
76
+ def id_card_can_be_printed
77
+ if !EmployeeProfile.find_by(user_id: user_id)&.photo&.attached?
78
+ errors.add(:base, "Add your photo to the ID card first")
79
+ elsif self.class.open_requests.exists?(user_id: user_id, category: "id_card")
80
+ errors.add(:base, "Your ID card print is already requested")
81
+ end
82
+ end
74
83
  end
75
84
  end
@@ -7,6 +7,8 @@ module HrLite
7
7
  belongs_to :user, class_name: HrLite.config.user_class
8
8
  belongs_to :leave_type
9
9
  belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
10
+ # Set when HR records leave the employee forgot to apply for.
11
+ belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
10
12
 
11
13
  validates :start_date, :end_date, presence: true
12
14
  validates :status, inclusion: { in: STATUSES }
@@ -22,7 +24,7 @@ module HrLite
22
24
  end
23
25
 
24
26
  before_validation :cache_days_count, on: :create
25
- after_create :notify_requested
27
+ after_create :notify_requested, unless: :recorded_by_hr?
26
28
 
27
29
  scope :pending, -> { where(status: "pending") }
28
30
  scope :approved, -> { where(status: "approved") }
@@ -209,13 +211,20 @@ module HrLite
209
211
  LeaveDayCounter.count(self) > balance.available(as_of: start_date)
210
212
  end
211
213
 
214
+ def recorded_by_hr? = created_by_id.present? && created_by_id != user_id
215
+
216
+ # Recorded leave is approved in the same transaction, so there is nothing
217
+ # left for a flow to route.
218
+ def open_approval_route = pending? && super
219
+
212
220
  def notify_requested
213
221
  Notifications.publish(
214
222
  "leave.requested",
215
223
  title: "#{HrLite.display_name(user)} applied for #{leave_type.name} (#{date_range_label})",
216
224
  body: reason.presence,
217
225
  path: "/admin/leave_requests/#{id}",
218
- bell_to: HrLite.admin_users
226
+ bell_to: HrLite.admin_users,
227
+ email_to: HrLite.admin_users
219
228
  )
220
229
  end
221
230
 
@@ -224,6 +233,8 @@ module HrLite
224
233
  # channel). Deliberately excludes the reason: dates are team-relevant,
225
234
  # the why is not.
226
235
  def notify_team
236
+ return if end_date < Date.current # nobody needs to hear who was out
237
+
227
238
  team = HrLite.active_employees.reject { |member| member.id == user_id }
228
239
  return if team.empty?
229
240
 
@@ -285,7 +296,7 @@ module HrLite
285
296
  clash = self.class.where(user_id: user_id, status: %w[pending approved])
286
297
  .where.not(id: id)
287
298
  .overlapping_range(start_date, end_date)
288
- errors.add(:base, "You already have leave overlapping these dates") if clash.exists?
299
+ errors.add(:base, "Leave already overlaps these dates") if clash.exists?
289
300
  end
290
301
 
291
302
  # A full-day leave over a day that already has a check-in makes no
@@ -296,7 +307,7 @@ module HrLite
296
307
 
297
308
  punched = AttendanceRecord.where(user_id: user_id, date: start_date..end_date)
298
309
  .where.not(check_in_at: nil)
299
- errors.add(:base, "You have marked attendance in this period") if punched.exists?
310
+ errors.add(:base, "Attendance is marked in this period") if punched.exists?
300
311
  end
301
312
 
302
313
  def sufficient_balance
@@ -12,6 +12,8 @@ module HrLite
12
12
  validates :effective_from, presence: true
13
13
  validates :version, numericality: { greater_than: 0 }
14
14
 
15
+ after_create_commit :announce, if: :published?
16
+
15
17
  scope :published, -> { where(published: true) }
16
18
  scope :live_on, ->(date) { published.where(effective_from: ..date) }
17
19
  scope :newest_first, -> { order(effective_from: :desc, version: :desc) }
@@ -34,6 +36,8 @@ module HrLite
34
36
  )
35
37
  end
36
38
 
39
+ def announcement? = !acknowledgement_required
40
+
37
41
  def acknowledged_by?(user)
38
42
  policy_acknowledgements.exists?(user_id: user.id)
39
43
  end
@@ -55,5 +59,16 @@ module HrLite
55
59
  rescue ActiveRecord::RecordNotUnique
56
60
  policy_acknowledgements.find_by!(user_id: user.id)
57
61
  end
62
+
63
+ private
64
+
65
+ def announce
66
+ staff = HrLite.active_employees
67
+ Notifications.publish(
68
+ "policy.published",
69
+ title: "#{announcement? ? 'Announcement' : 'Policy'}: #{title}#{' (please read and acknowledge)' unless announcement?}",
70
+ body: body.truncate(300), path: "/policies/#{id}", bell_to: staff, email_to: staff
71
+ )
72
+ end
58
73
  end
59
74
  end
@@ -7,7 +7,7 @@ module HrLite
7
7
 
8
8
  validates :weekend_policy, inclusion: { in: WEEKEND_POLICIES }
9
9
  validates :employee_code_prefix, presence: true,
10
- format: { with: /\A[A-Za-z]{1,10}\z/, message: "letters only, max 10" }
10
+ format: { with: /\A[A-Za-z]{1,10}-?\z/, message: "letters only (max 10), optionally ending in -" }
11
11
 
12
12
  def self.instance
13
13
  first_or_create!
@@ -11,8 +11,11 @@ module HrLite
11
11
  STATUSES = %w[draft submitted verified rejected].freeze
12
12
  REGIMES = %w[new old].freeze
13
13
 
14
+ # inverse_of is load-bearing, not decoration: the foreign key is not
15
+ # derived from the class name, so without it a nested item on an UNSAVED
16
+ # declaration cannot see its parent and fails "declaration must exist".
14
17
  has_many :tax_declaration_items, -> { order(:section) },
15
- class_name: "HrLite::TaxDeclarationItem",
18
+ class_name: "HrLite::TaxDeclarationItem", inverse_of: :declaration,
16
19
  foreign_key: :declaration_id, dependent: :destroy
17
20
  accepts_nested_attributes_for :tax_declaration_items, allow_destroy: true
18
21
 
@@ -0,0 +1,89 @@
1
+ <% content_for(:page_title) { "Documents" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Documents</h1></div>
3
+
4
+ <section class="hrl-card">
5
+ <p class="hrl-muted hrl-small">
6
+ Seeing that a document exists and opening it are different things — a
7
+ passport is visible here, but the file itself only opens for whoever its
8
+ visibility allows.
9
+ </p>
10
+ <div class="hrl-row">
11
+ <a class="hrl-btn <%= "hrl-btn--primary" if @category.nil? %>"
12
+ href="<%= hr_lite.admin_documents_path %>">All</a>
13
+ <% @categories.each do |category| %>
14
+ <a class="hrl-btn <%= "hrl-btn--primary" if @category == category %>"
15
+ href="<%= hr_lite.admin_documents_path(category: category) %>"><%= category.humanize %></a>
16
+ <% end %>
17
+ </div>
18
+ </section>
19
+
20
+ <section class="hrl-card">
21
+ <h2 class="hrl-card__title">Waiting to be checked (<%= @pending.size %>)</h2>
22
+ <% if @pending.empty? %>
23
+ <p class="hrl-muted">Nothing pending.</p>
24
+ <% else %>
25
+ <div class="hrl-table-wrap">
26
+ <table class="hrl-table hrl-table--stack">
27
+ <thead><tr><th>Person</th><th>Document</th><th>File</th><th></th></tr></thead>
28
+ <tbody>
29
+ <% @pending.each do |doc| %>
30
+ <tr>
31
+ <td data-label="Person"><%= hr_display_name(doc.user) %></td>
32
+ <td data-label="Document">
33
+ <%= doc.title %>
34
+ <div class="hrl-small hrl-muted"><%= doc.category.humanize %></div>
35
+ </td>
36
+ <td data-label="File">
37
+ <% if doc.readable_by?(hr_current_user) && doc.file.attached? %>
38
+ <a class="hrl-small" href="<%= hr_lite.download_document_path(doc) %>">Open</a>
39
+ <% else %>
40
+ <span class="hrl-small hrl-muted">Not yours to open</span>
41
+ <% end %>
42
+ </td>
43
+ <td data-label="">
44
+ <%= button_to "Verify", hr_lite.verify_admin_document_path(doc),
45
+ method: :post, class: "hrl-btn hrl-btn--primary" %>
46
+ <%= form_with url: hr_lite.reject_admin_document_path(doc), method: :post, local: true do %>
47
+ <%= text_field_tag :note, nil, placeholder: "What is wrong with it" %>
48
+ <%= submit_tag "Reject", class: "hrl-btn" %>
49
+ <% end %>
50
+ </td>
51
+ </tr>
52
+ <% end %>
53
+ </tbody>
54
+ </table>
55
+ </div>
56
+ <% end %>
57
+ </section>
58
+
59
+ <section class="hrl-card">
60
+ <h2 class="hrl-card__title">Everything on file</h2>
61
+ <div class="hrl-table-wrap">
62
+ <table class="hrl-table hrl-table--stack">
63
+ <thead><tr><th>Person</th><th>Document</th><th>Expires</th><th>Checked</th></tr></thead>
64
+ <tbody>
65
+ <% @documents.each do |doc| %>
66
+ <tr>
67
+ <td data-label="Person">
68
+ <a href="<%= hr_lite.admin_employee_documents_path(user_id: doc.user_id) %>">
69
+ <%= hr_display_name(doc.user) %>
70
+ </a>
71
+ </td>
72
+ <td data-label="Document"><%= doc.title %></td>
73
+ <td data-label="Expires">
74
+ <% if doc.expires_on %>
75
+ <span class="<%= "hrl-badge hrl-badge--bad" if doc.expired? %>">
76
+ <%= doc.expires_on.strftime("%d %b %Y") %>
77
+ </span>
78
+ <% else %>—<% end %>
79
+ </td>
80
+ <td data-label="Checked">
81
+ <%= hrl_request_status_badge(doc.verification == "verified" ? "approved" : doc.verification) %>
82
+ </td>
83
+ </tr>
84
+ <% end %>
85
+ </tbody>
86
+ </table>
87
+ </div>
88
+ <%= hrl_pagination %>
89
+ </section>
@@ -0,0 +1,42 @@
1
+ <% content_for(:page_title) { hr_display_name(@employee) } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title"><%= hr_display_name(@employee) %> — documents</h1>
4
+ </div>
5
+
6
+ <% if @missing.any? %>
7
+ <section class="hrl-card hrl-card--warn">
8
+ <h2 class="hrl-card__title">Not on file</h2>
9
+ <p><%= @missing.map(&:humanize).to_sentence %></p>
10
+ </section>
11
+ <% end %>
12
+
13
+ <section class="hrl-card">
14
+ <% if @documents.empty? %>
15
+ <p class="hrl-muted">Nothing uploaded.</p>
16
+ <% else %>
17
+ <div class="hrl-table-wrap">
18
+ <table class="hrl-table hrl-table--stack">
19
+ <thead><tr><th>Document</th><th>Expires</th><th>Checked</th><th></th></tr></thead>
20
+ <tbody>
21
+ <% @documents.each do |doc| %>
22
+ <tr>
23
+ <td data-label="Document">
24
+ <strong><%= doc.title %></strong>
25
+ <div class="hrl-small hrl-muted"><%= doc.category.humanize %></div>
26
+ </td>
27
+ <td data-label="Expires"><%= doc.expires_on&.strftime("%d %b %Y") || "—" %></td>
28
+ <td data-label="Checked">
29
+ <%= hrl_request_status_badge(doc.verification == "verified" ? "approved" : doc.verification) %>
30
+ </td>
31
+ <td data-label="">
32
+ <% if doc.readable_by?(hr_current_user) && doc.file.attached? %>
33
+ <a class="hrl-small" href="<%= hr_lite.download_document_path(doc) %>">Open</a>
34
+ <% end %>
35
+ </td>
36
+ </tr>
37
+ <% end %>
38
+ </tbody>
39
+ </table>
40
+ </div>
41
+ <% end %>
42
+ </section>
@@ -57,6 +57,11 @@
57
57
  <div class="hrl-field"><%= f.label :work_location %><%= f.text_field :work_location %></div>
58
58
  <div class="hrl-field"><%= f.label :date_of_birth %><%= f.date_field :date_of_birth %></div>
59
59
  <div class="hrl-field"><%= f.label :date_of_joining %><%= f.date_field :date_of_joining %></div>
60
+ <div class="hrl-field">
61
+ <%= f.label :blood_group %>
62
+ <%= f.select :blood_group, HrLite::EmployeeProfile::BLOOD_GROUPS, include_blank: "—" %>
63
+ </div>
64
+ <div class="hrl-field"><%= f.label :emergency_contact, "Emergency contact (printed on the ID card)" %><%= f.telephone_field :emergency_contact %></div>
60
65
  <div class="hrl-field">
61
66
  <%= f.label :date_of_exit, "Date of exit (leave blank while employed)" %>
62
67
  <%= f.date_field :date_of_exit %>
@@ -3,6 +3,7 @@
3
3
  <h1 class="hrl-page__title"><%= hr_display_name(@profile.user) %> <span class="hrl-muted">(<%= @profile.employee_code %>)</span></h1>
4
4
  <div class="hrl-page__actions">
5
5
  <a class="hrl-btn" href="<%= hr_lite.edit_admin_employee_path(@profile) %>">Edit</a>
6
+ <a class="hrl-btn" href="<%= hr_lite.id_card_path(user_id: @profile.user_id) %>">ID card</a>
6
7
  <% if hr_superadmin? %>
7
8
  <a class="hrl-btn" href="<%= hr_lite.new_admin_employee_appraisal_path(@profile) %>">New appraisal</a>
8
9
  <a class="hrl-btn" href="<%= hr_lite.new_admin_employee_designation_change_path(@profile) %>">Role change</a>
@@ -7,6 +7,9 @@
7
7
  <%= @request.created_at.to_date.strftime("%d %b %Y") %> ·
8
8
  <%= hrl_request_status_badge(@request.status) %>
9
9
  </p>
10
+ <% if @request.category == "id_card" %>
11
+ <a class="hrl-btn hrl-mt" href="<%= hr_lite.id_card_path(user_id: @request.user_id) %>">Open the ID card to print</a>
12
+ <% end %>
10
13
  <% if @request.body.present? %>
11
14
  <div class="hrl-prose"><%= simple_format(@request.body) %></div>
12
15
  <% end %>
@@ -1,6 +1,9 @@
1
1
  <% content_for(:page_title) { "Leave approvals" } %>
2
2
  <div class="hrl-page__head">
3
3
  <h1 class="hrl-page__title">Leave approvals</h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn" href="<%= hr_lite.new_admin_leave_request_path %>">Record leave</a>
6
+ </div>
4
7
  </div>
5
8
 
6
9
  <%= render "hr_lite/admin/shared/approvals_tabs", active: :leaves %>
@@ -0,0 +1,35 @@
1
+ <% content_for(:page_title) { "Record leave" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Record leave for an employee</h1>
4
+ <p class="hrl-page__sub">For leave somebody took but never applied for. It is saved as approved, and they get an email.</p>
5
+ </div>
6
+
7
+ <section class="hrl-card">
8
+ <%= form_with model: @request, url: hr_lite.admin_leave_requests_path, method: :post, local: true do |f| %>
9
+ <%= render "hr_lite/shared/form_errors", record: @request %>
10
+ <div class="hrl-field">
11
+ <%= label_tag :employee_code, "Employee (type a name or code)" %>
12
+ <%= text_field_tag :employee_code, params[:employee_code], list: "hrl-employees", required: true, autocomplete: "off" %>
13
+ <datalist id="hrl-employees">
14
+ <% @people.each do |p| %>
15
+ <option value="<%= p.employee_code %>"><%= hr_display_name(p.user) %></option>
16
+ <% end %>
17
+ </datalist>
18
+ </div>
19
+ <div class="hrl-field">
20
+ <%= f.label :leave_type_id, "Type" %>
21
+ <%= f.collection_select :leave_type_id, HrLite::LeaveType.active, :id, :name %>
22
+ </div>
23
+ <div class="hrl-field"><%= f.label :start_date, "From" %><%= f.date_field :start_date %></div>
24
+ <div class="hrl-field"><%= f.label :end_date, "To" %><%= f.date_field :end_date %></div>
25
+ <div class="hrl-field hrl-field--check">
26
+ <%= f.check_box :half_day %>
27
+ <%= f.label :half_day, "Half day (single-day only)" %>
28
+ </div>
29
+ <div class="hrl-field"><%= f.label :reason, "Note (optional)" %><%= f.text_area :reason %></div>
30
+ <div class="hrl-form-actions">
31
+ <%= f.submit "Record leave", class: "hrl-btn hrl-btn--primary" %>
32
+ <a class="hrl-btn" href="<%= hr_lite.admin_leave_requests_path %>">Cancel</a>
33
+ </div>
34
+ <% end %>
35
+ </section>
@@ -0,0 +1,43 @@
1
+ <% content_for(:page_title) { "Loans" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Loans &amp; advances</h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_loan_path %>">Record a loan</a>
6
+ </div>
7
+ </div>
8
+
9
+ <section class="hrl-card">
10
+ <p>Outstanding across every active loan: <strong><%= hrl_money(@outstanding) %></strong></p>
11
+ <div class="hrl-row">
12
+ <% HrLite::Loan::STATUSES.each do |status| %>
13
+ <a class="hrl-btn <%= "hrl-btn--primary" if @status == status %>"
14
+ href="<%= hr_lite.admin_loans_path(status: status) %>"><%= status.humanize %></a>
15
+ <% end %>
16
+ </div>
17
+ </section>
18
+
19
+ <section class="hrl-card">
20
+ <% if @loans.empty? %>
21
+ <p class="hrl-muted">Nothing <%= @status %>.</p>
22
+ <% else %>
23
+ <div class="hrl-table-wrap">
24
+ <table class="hrl-table hrl-table--stack">
25
+ <thead><tr><th>Person</th><th>Principal</th><th>Instalment</th><th>Outstanding</th><th></th></tr></thead>
26
+ <tbody>
27
+ <% @loans.each do |loan| %>
28
+ <tr>
29
+ <td data-label="Person"><%= hr_display_name(loan.user) %></td>
30
+ <td data-label="Principal" class="hrl-num"><%= hrl_money(loan.principal) %></td>
31
+ <td data-label="Instalment" class="hrl-num"><%= hrl_money(loan.monthly_instalment) %></td>
32
+ <td data-label="Outstanding" class="hrl-num"><%= hrl_money(loan.outstanding) %></td>
33
+ <td data-label="">
34
+ <a class="hrl-small" href="<%= hr_lite.admin_loan_path(loan) %>">Open</a>
35
+ </td>
36
+ </tr>
37
+ <% end %>
38
+ </tbody>
39
+ </table>
40
+ </div>
41
+ <% end %>
42
+ <%= hrl_pagination %>
43
+ </section>