Module: CrmHelper

Included in:
Auth::AuthenticationsController
Defined in:
app/helpers/crm_helper.rb

Instance Method Summary collapse

Instance Method Details

#alert(options = {}) ⇒ Object

Generates a bootstrap style alert, pass a severity matching one of bootstrap
classes (info default danger warning), a block for the content or :content
and dismissible: true if you want a close button



520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
# File 'app/helpers/crm_helper.rb', line 520

def alert(options = {}, &)
  options[:dismissible] = true if options[:dismissible].nil?
  options[:severity] ||= 'danger'
  options[:severity] = 'warning' if options[:severity] == 'alert'
  options[:severity] = 'primary' if options[:severity] == 'notice'
  css_class = ['alert', "alert-#{options[:severity]}"]
  if options[:dismissible]
    css_class += %w[alert-dismissible fade show]
    button_html = button_tag(
      '',
      { type: 'button', class: 'btn-close', 'data-bs-dismiss': 'alert', 'aria-label': 'Close' }
    )
  end
  (:div, class: css_class.join(' '), role: 'alert') do
    (options[:content].presence || (:span, &)).html_safe + button_html
  end
end

#array_to_list(values, options = {}) ⇒ Object



1052
1053
1054
1055
1056
1057
1058
# File 'app/helpers/crm_helper.rb', line 1052

def array_to_list(values, options = {})
  (:ul, **options) do
    values.each do |v|
      concat (:li, v)
    end
  end
end

#attr_display(attr_label, attr_value = nil, skip_if_blank = false, options = {}) ⇒ Object

Render a 2-column "label/value" row (Bootstrap-based) for CRM detail screens.

Supported options:

  • format: Controls formatting of scalar values.
    • :datetime => render_datetime(value)
    • :date => render_date(value)
    • :currency => number_to_currency(value)
    • :link => link_to(value, value)
    • default (nil): inferred from value type (Date/Time) or displayed as-is
  • prefix: String prepended to the formatted value (e.g., '$').
  • suffix: String appended to the formatted value (e.g., 'ms').
  • value_extra_css_class: Extra CSS classes applied to the value column (col-8).
  • value_style: Inline style string applied to the value column (col-8).
  • tooltip: Sets the title attribute on the value column (simple browser tooltip).
  • tooltip_html: When true, also sets data-bs-toggle="tooltip" (Bootstrap tooltip opt-in).

Notes:

  • Arrays render as a <ul> list (single-element arrays render as the single value).
  • Hashes render as a <ul> list of "key: value" items.
  • If you need custom markup, prefer passing a pre-built tag as attr_value
    (e.g., tag.span(...)) rather than yielding a block.

Parameters:

  • attr_label (String, Symbol)

    Label to display. Symbols are humanized.

  • attr_value (Object) (defaults to: nil)

    Value to display. If a block is provided, the block output is used instead.

  • skip_if_blank (Boolean) (defaults to: false)

    When true, returns nil if attr_value is blank.

  • options (Hash) (defaults to: {})

    Formatting/styling options.



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'app/helpers/crm_helper.rb', line 183

def attr_display(attr_label, attr_value = nil, skip_if_blank = false, options = {})
  return if skip_if_blank && attr_value.blank?

  attr_label = attr_label.to_s.humanize if attr_label.is_a?(Symbol)
  display_value = attr_display_value(attr_value, options)
  (:div, class: 'row py-1 px-0 m-0 w-100') do
    concat (:div, raw("#{attr_label}:"), class: 'col-4 px-0 pe-2 text-muted')
    value_attrs = {
      class: "col-8 px-0 #{options[:value_extra_css_class]}",
      style: options[:value_style]
    }
    if options[:tooltip].present?
      value_attrs[:title] = options[:tooltip]
      value_attrs['data-bs-toggle'] = 'tooltip' if options[:tooltip_html]
    end

    concat (:div, (block_given? ? yield : raw(display_value)), **value_attrs)
  end
end

#attr_display_value(attr_value, options) ⇒ Object



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'app/helpers/crm_helper.rb', line 98

def attr_display_value(attr_value, options)
  return unless attr_value = attr_value.presence

  display_value = nil

  case attr_value
  when Array # Array
    attr_value = attr_value.compact
    if attr_value.size > 1
      multi_values = attr_value
    else
      attr_value = attr_value.first
    end
  when Hash
    multi_values = attr_value.map { |k, v| "#{k}: #{v}" }
  end
  # if we have an array with more than 1 element, we display as multi value with a list
  if multi_values
    display_value = (:ul, class: 'list-unstyled', style: 'margin-bottom:0') do
      multi_values.each do |v|
        concat (:li, v)
      end
    end
  else
    format = options[:format]
    if format.nil?
      case attr_value
      when Date
        format = :date
      when DateTime, Time
        format = :datetime
      end
    end
    if (display_value = attr_value).present?
      case format
      when :datetime
        display_value = render_datetime(display_value)
      when :date
        display_value = render_date(display_value)
      when :currency
        display_value = number_to_currency(display_value)
      when :link
        display_value = link_to(display_value, display_value)
      end
      display_value = "#{display_value} #{options[:suffix]}" if options[:suffix]
      display_value = "#{options[:prefix]} #{display_value}" if options[:prefix]
    end
  end
  display_value
end

#attr_displays(object, *args) ⇒ Object



149
150
151
152
153
154
155
# File 'app/helpers/crm_helper.rb', line 149

def attr_displays(object, *args)
  capture do
    args.each do |arg|
      concat attr_display(arg.to_sym, object.send(arg))
    end
  end
end

#attr_list_display(attr_label, attr_value, skip_if_blank = false, options = {}) ⇒ Object



226
227
228
229
230
231
232
233
234
235
# File 'app/helpers/crm_helper.rb', line 226

def attr_list_display(attr_label, attr_value, skip_if_blank = false, options = {})
  return if skip_if_blank && attr_value.blank?

  display_value = attr_display_value(attr_value, options)
   :li, { class: 'list-group-item' } do
    (:span, (:strong, raw("#{attr_label}:")), { class: 'card-label float-start' }) +
      (:span, raw(display_value), { class: 'card-value float-end' }) +
      (:div, nil, { class: 'clearfix' })
  end
end

#audit_button(stamped_resource, path_to_audit: nil, main_link_class: nil) ⇒ Object



350
351
352
353
354
355
356
357
358
359
# File 'app/helpers/crm_helper.rb', line 350

def audit_button(stamped_resource, path_to_audit: nil, main_link_class: nil)
  return unless stamped_resource

  list = []
  list << fa_icon('eye', title: 'Audit')
  list += audit_links(stamped_resource, path_to_audit: path_to_audit)
  options = { dropdown_options: { right_menu: true } }
  options[:main_link_class] = main_link_class if main_link_class
  render_simple_drop_down list, options
end


361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
# File 'app/helpers/crm_helper.rb', line 361

def audit_links(stamped_resource, path_to_audit: nil)
  list = []

  path_to_audit ||= begin
    polymorphic_path([stamped_resource, :audit_trails])
  rescue StandardError
    nil
  end

  created_at = render_datetime(stamped_resource.created_at)
  creator_name = audit_trail_creator_name(stamped_resource)
  strc = ['Created']
  strc << "on #{created_at}" if created_at
  strc << "by #{creator_name}" if creator_name
  list << link_to(strc.join(' '), path_to_audit) if strc.length > 1

  updated_at = render_datetime(stamped_resource.updated_at)
  updater_name = stamped_resource.try(:updater).try(:full_name)
  stru = ['Updated']
  stru << "on #{updated_at}" if updated_at
  stru << " by #{updater_name}" if updater_name
  list << link_to(stru.join(' '), path_to_audit) if stru.length > 1

  if stamped_resource.instance_of?(Activity)
    completion_datetime = render_datetime(stamped_resource.completion_datetime)
    completer_name = stamped_resource.closed_by.try(:full_name)
    stra = ['Closed']
    stra << "on #{completion_datetime}" if completion_datetime
    stra << "by #{completer_name}" if completer_name
    list << link_to(stra.join(' '), path_to_audit) if stra.length > 1
  end

  list << link_to('Audit Trail', path_to_audit) if path_to_audit

  list
end

#audit_trail_creator_name(stamped_resource) ⇒ Object

Look up the true creator from the audit trail's "create" event.
Uses unfiltered versions to find the original creator, regardless of any
model-specific filtering (e.g., for reused IDs). Falls back to creator_id
if no create event is found.



402
403
404
405
406
407
408
409
410
411
# File 'app/helpers/crm_helper.rb', line 402

def audit_trail_creator_name(stamped_resource)
  return stamped_resource.try(:creator).try(:full_name) unless stamped_resource.respond_to?(:versions)

  # Use unfiltered versions to find the TRUE original creator
  create_version = stamped_resource.versions.find_by(event: 'create')
  creator_from_audit = create_version&.responsible_party&.full_name

  # Fall back to creator_id if no create event found in audit trail
  creator_from_audit.presence || stamped_resource.try(:creator).try(:full_name)
end

#bootstrap_class_for(flash_type) ⇒ Object



473
474
475
# File 'app/helpers/crm_helper.rb', line 473

def bootstrap_class_for(flash_type)
  { success: 'success', error: 'danger', warning: 'warning', info: 'info', notice: 'success' }[flash_type.to_s.to_sym] || flash_type.to_s
end

#centered_row(options = {}) ⇒ Object



1046
1047
1048
1049
1050
# File 'app/helpers/crm_helper.rb', line 1046

def centered_row(options = {})
  (:div, class: "d-flex justify-content-center align-items-center #{options[:class]}") do
    options[:content] || yield
  end
end

#counter_badge(number, skip_if_zero = true, css_class = nil) ⇒ Object



26
27
28
29
30
31
# File 'app/helpers/crm_helper.rb', line 26

def counter_badge(number, skip_if_zero = true, css_class = nil)
  return unless number && (number.positive? || !skip_if_zero)

  css_class = 'bg-secondary' if css_class.blank?
  counter_span(number, css_class)
end

#counter_span(number, css_class) ⇒ Object



33
34
35
# File 'app/helpers/crm_helper.rb', line 33

def counter_span(number, css_class)
  (:span, number, class: "badge #{css_class}")
end

#crm_home_path(employee = nil) ⇒ Object

CRM home path - redirects to company or employee dashboard
This is also defined in CrmController but needs to be available in views
rendered by non-CRM controllers (e.g., Auth::AuthenticationsController)



5
6
7
8
9
10
11
12
# File 'app/helpers/crm_helper.rb', line 5

def crm_home_path(employee = nil)
  employee ||= current_user
  if employee.nil? || (employee.try(:employee_record).try(:default_dashboard) == 'company')
    dashboard_path
  else
    employee_dashboard_path(employee.id)
  end
end


1071
1072
1073
1074
1075
1076
1077
# File 'app/helpers/crm_helper.rb', line 1071

def delete_link(resource, options = {})
  return unless can?(:destroy, resource) || options[:skip_permission_check]

  resource_path = options[:delete_path] || polymorphic_path(resource, destroy: options[:destroy])
  link_to(fa_icon('trash', text: 'Delete'), resource_path, class: 'btn btn-link',
                                                           data: { turbo_confirm: 'Are you sure you wish to delete this record?', turbo_method: :delete })
end

#duration_for_select(increments = 900) ⇒ Object



455
456
457
458
459
460
461
462
463
464
# File 'app/helpers/crm_helper.rb', line 455

def duration_for_select(increments = 900)
  duration = 0
  end_time = 36_000
  res = []
  loop do
    res << [Time.at(duration).utc.strftime('%H:%M'), duration]
    break unless (duration += increments) < end_time
  end
  res
end

#dynamic_status_flow(obj) ⇒ Object

Given a state_machine model will dynamically pull all the possible states and generate a status flow selecting the current state



417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
# File 'app/helpers/crm_helper.rb', line 417

def dynamic_status_flow(obj)
  # <div class="row">
  #	<div class="col-md-10 col-md-offset-1 progress-status"

  (:div, class: 'row', id: 'statusheader') do
    obj.try(:state_description)
    (:div, class: 'progress-status col-md-12 center', id: 'statusflow') do
      (:ul) do
        res = []
        progres_position = false
        obj.class.state_machines[:state].states.map(&:name).each do |s|
          description = obj.try(:state_description, s)
          state = obj.class.human_state_name(s)
          progres_position = true if obj.state.to_sym == s
          res << event_status(state, obj.state.to_sym == s, progres_position, nil, description)
          # res << event_status( state, obj.state.to_sym == s, future_status: progres_position )
        end
        raw(res.join("\n"))
      end
    end
  end
end


1079
1080
1081
1082
1083
1084
1085
1086
# File 'app/helpers/crm_helper.rb', line 1079

def edit_link(resource, options = {})
  return unless can?(:update, resource)

  resource_path = options[:edit_path] || polymorphic_path(resource, action: :edit)
  link_to(fa_icon('pen-to-square', text: 'Edit'),
          resource_path,
          class: 'btn btn-outline-primary')
end

#friendly_date_range(from_date, to_date) ⇒ Object



466
467
468
469
470
471
# File 'app/helpers/crm_helper.rb', line 466

def friendly_date_range(from_date, to_date)
  s = +''
  s << "From #{from_date.to_fs(:crm_dateonly)}" if from_date
  s << " Until #{to_date.to_fs(:crm_dateonly)}" if to_date
  s.squish.presence
end

#identifiers(ids = {}) ⇒ Object



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'app/helpers/crm_helper.rb', line 77

def identifiers(ids = {})
  ar_ids = if ids.is_a? Hash
             ids.compact_blank.map { |k, v| clipboard_copy("#{k}: #{v}", mode: :text, copy_value: v, button_class: 'btn btn-link p-0 text-inherit px-2 nav-link') }
           else
             [ids].flatten.compact_blank.map do |v|
               # Only items from clipboard_copy are clipboard buttons (keep link color)
               # Other items (fa_icon, etc.) are just displayed as-is with body text color
               if v.respond_to?(:html_safe?) && v.html_safe? && v.to_s.include?('clipboard')
                 v
               else
                 tag.span(v, class: 'px-2 text-body')
               end
             end
           end
  capture do
    ar_ids.each do |id_label|
      concat tag.li(id_label, class: 'nav-item d-flex align-items-center')
    end
  end
end


1038
1039
1040
# File 'app/helpers/crm_helper.rb', line 1038

def modal_close_button
  button_tag 'Close', class: 'btn btn-outline-primary', data: { 'bs-dismiss': 'modal' }
end


1042
1043
1044
# File 'app/helpers/crm_helper.rb', line 1042

def modal_close_on_submit_js
  render partial: '/shared/modal_close_on_submit_js'
end


1005
1006
1007
1008
1009
1010
1011
1012
1013
# File 'app/helpers/crm_helper.rb', line 1005

def modal_dialog(modal_id, content = nil, large = false)
  (:div, class: 'modal fade', id: modal_id, tabindex: -1, role: 'dialog', aria: { labeledby: "#{modal_id}Label", hidden: true }) do
    (:div, class: "modal-dialog #{'modal-lg' if large == true}") do
      (:div, '', class: 'modal-content') do
        content || (yield if block_given?)
      end
    end
  end
end


1026
1027
1028
1029
1030
# File 'app/helpers/crm_helper.rb', line 1026

def modal_dialog_body(content = nil)
  (:div, class: 'modal-body') do
    content || (yield if block_given?)
  end
end


1032
1033
1034
1035
1036
# File 'app/helpers/crm_helper.rb', line 1032

def modal_dialog_footer(content = nil)
  (:div, class: 'modal-footer') do
    (block_given? ? yield : content)
  end
end


1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
# File 'app/helpers/crm_helper.rb', line 1015

def modal_dialog_header(modal_title = nil)
  (:div, class: 'modal-header') do
    concat button_tag('', class: 'btn-close', data: { 'bs-dismiss': 'modal' }, aria: { hidden: true })
    if modal_title
      concat (:h4, modal_title, class: 'modal-title')
    elsif block_given?
      concat yield
    end
  end
end

#multi_locale_attr_display(attr_label, object: nil, method: nil, locales: nil) ⇒ Object



203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'app/helpers/crm_helper.rb', line 203

def multi_locale_attr_display(attr_label, object: nil, method: nil, locales: nil)
  values = []
  translations = object.translations.dup
  locales ||= object.content_locales_to_render if object.respond_to?(:content_locales_to_render)
  locales ||= Mobility.available_locales
  locales.each do |locale|
    # Because we do not want to display fallbacks, we will query the translation directly
    if object && method
      value = if locale == I18n.default_locale
                object.try(method).presence
              else
                translations.dig(locale.to_s, method.to_s).presence
              end
    elsif block_given?
      value = yield
    else
      raise 'Missing block or object and method for multi_locale_attr_display'
    end
    values << "#{tag.span(locale, class: 'badge bg-light text-dark', style: 'width:5em')} #{value}".html_safe if value.present?
  end
  attr_display(attr_label, values, true)
end

Two-letter initials for CRM navbar: first letter of first name and of last name.
Middle name is never used. If structured names are blank, uses first and last word of full_name.



1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
# File 'app/helpers/crm_helper.rb', line 1129

def navbar_user_initials(party)
  first = party.first_name.to_s.strip
  last = party.last_name.to_s.strip

  initials =
    if first.present? && last.present?
      "#{first[0]}#{last[0]}"
    elsif first.present?
      first.size >= 2 ? first[0..1] : "#{first[0]}#{first[0]}"
    elsif last.present?
      last.size >= 2 ? last[0..1] : "#{last[0]}#{last[0]}"
    else
      parts = party.full_name.to_s.split.reject(&:blank?)
      if parts.size >= 2
        "#{parts.first[0]}#{parts.last[0]}"
      elsif parts.size == 1 && parts.first.present?
        w = parts.first
        w.size >= 2 ? w[0..1] : "#{w[0]}#{w[0]}"
      end
    end

  initials.to_s.upcase.presence || '?'
end

#notes_popover(notes) ⇒ Object

Creates a bootstrap popover style trimming the notes for text only



1061
1062
1063
1064
1065
1066
1067
1068
1069
# File 'app/helpers/crm_helper.rb', line 1061

def notes_popover(notes)
  text_notes = text_only(notes)
  return if text_notes.blank?

  button_tag(type: 'button', class: 'btn btn-outline-primary', title: 'Notes',
             data: { 'bs-container': 'body', 'bs-toggle': 'popover', 'bs-content': text_notes }) do
    fa_icon('comment')
  end
end

#paginate_bar(pagy = @pagy, options = {}) ⇒ Object



249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'app/helpers/crm_helper.rb', line 249

def paginate_bar(pagy = @pagy, options = {})
  return unless pagy

  always_show_info = options.delete(:always_show_info)
  always_show_info = true if always_show_info.nil?
  fragment_id = options.delete(:id).presence || 'paginate_bar'
  per_page_options = options.delete(:per_page_options)

  # If only one page and not forcing info display, return early
  return unless pagy.last > 1 || always_show_info || per_page_options.present?

  # Ensure pagination links stay inside the requesting Turbo Frame by default
  link_extra = options[:link_extra]
  unless link_extra.present?
    current_frame_id = request.headers['Turbo-Frame'].presence || params[:target_id].presence
    link_extra = %(data-turbo-frame="#{current_frame_id}") if current_frame_id.present?
  end

  nav_options = options.dup
  nav_options[:link_extra] = link_extra if link_extra.present?

  tag.div(id: fragment_id, class: 'd-flex my-2 justify-content-between align-items-center flex-wrap gap-2') do
    # Navigation (only if more than one page)
    nav_section = if pagy.last > 1
                    nav_html = if pagy.is_a?(Pagy::Offset::Countless)
                                 pagy_countless_prev_next_nav(pagy, link_extra:)
                               else
                                 html = pagy.series_nav(:bootstrap, **nav_options)
                                 html = html.gsub('<a ', "<a #{link_extra} ") if link_extra.present? && link_extra.include?('data-turbo-frame') && html.exclude?('data-turbo-frame')
                                 html
                               end
                    tag.div(raw(nav_html))
                  else
                    tag.div # Empty div to maintain flex layout
                  end

    # Right side: info + per-page selector
    # Countless paginators don't know the true total — skip the misleading "Page X of Y" tag
    info_section = tag.div(class: 'd-flex align-items-center gap-3') do
      info_html = pagy.is_a?(Pagy::Offset::Countless) ? ''.html_safe : tag.div(raw(pagy.info_tag), class: 'text-muted text-nowrap')
      per_page_html = per_page_options.present? ? per_page_selector(per_page_options, pagy.limit) : ''.html_safe
      safe_join([info_html, per_page_html])
    end

    safe_join([nav_section, info_section])
  end
end

#panel(title, panel_open = true, options = {}) ⇒ Object

options:
open: default state, open or close



540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
# File 'app/helpers/crm_helper.rb', line 540

def panel(title, panel_open = true, options = {}, &)
  id = options[:id] || "card-#{SecureRandom.hex(10)}".parameterize
  link_title = title
  options.delete(:skip_body_wrap)
  options[:mode] = :link if options[:link].present?
  options[:mode] = :trash if options[:trash].present?
  panel_class = options[:panel_class]
  menu_icon = options[:menu_icon]
  panel_header_class = options[:panel_header_class]
  panel_header_class = 'h5' if panel_header_class.nil? # allows for blank to override
  (
    :div,
    class: "card w-100 #{panel_class} #{options[:add_class]} #{options[:panel_size]}",
    id: id
  ) do
    skip_title_span = options.delete(:skip_title_span)
    panel_body_class = options.delete(:panel_body_class)
    panel_header_outer_class = options.delete(:panel_header_outer_class) ||
      'card-header d-flex justify-content-between align-items-center'
    dropdown_toggle_class = options.delete(:dropdown_toggle_class) || 'card-link btn btn-outline-primary dropdown-toggle'
    links = (:div, class: 'card-text', aria: { labelledby: "heading-links-#{id}" }) do
      case options[:mode]
      when :dropdown
        (:div, class: 'dropdown') do
          (:button, fa_icon(:wrench), class: dropdown_toggle_class,
                                                 data: { 'bs-toggle': 'dropdown', 'bs-flip': false },
                                                 aria: { haspopup: true, expanded: false }) +
            (:ul, class: 'dropdown-menu dropdown-menu-end',
                             role: 'menu',
                             aria: { labelledby: "dropdown-#{id}" },
                             style: options[:dropdown_style]) do
              yield(:dropdown)
            end
        end
      when :link
        if options[:link].is_a?(Array)
          (:ul, options[:link].map { |l| (:li, l, class: 'list-inline-item') }.join.html_safe, class: 'm-0 list-inline')
        else
          link_to(options[:link], class: 'btn btn-outline-primary') do
            options[:link_label] || fa_icon('up-right-from-square')
          end
        end
      when :trash
        options[:trash]
      else
        fa_icon(menu_icon || 'bookmark') if menu_icon
      end
    end

    title_inner = skip_title_span ? link_title : (:span, link_title)
    header = (
      :a,
      title_inner,
      class: "#{panel_header_class} card-title #{'collapsed' if panel_open}",
      data: {
        'bs-target': "#heading-#{id}",
        'bs-toggle': 'collapse'
      },
      role: 'button',
      href: "#heading-#{id}",
      aria: {
        expanded: panel_open,
        controls: "heading-#{id}"
      }
    )

    header = (:div, class: panel_header_outer_class) do
      (:span, header, class: 'flex-grow-1') + (:span, links)
    end

    # Collapse body always present; if href provided, insert a native lazy Turbo Frame that fetches when visible
    body_classes = ['card-body', 'collapse', ('show' if panel_open), panel_body_class].compact
    content = (
      :div,
      class: body_classes.join(' '),
      id: "heading-#{id}"
    ) do
      if options[:href].present?
        turbo_frame_tag("frame-#{id}", src: options[:href], loading: :lazy) { capture(&) if block_given? }
      elsif block_given?
        capture(&)
      end
    end

    header + content
  end
end

#possible_events(obj) ⇒ Object



440
441
442
# File 'app/helpers/crm_helper.rb', line 440

def possible_events(obj)
  obj.state_transitions.map(&:event).reject { |evt| obj.try(:exclude_manually_initiated_event?, evt.to_sym) }.sort
end

#pretty_json_includeObject

Legacy method - kept for backwards compatibility
No longer needed since we use Stimulus lazy loading



65
66
67
# File 'app/helpers/crm_helper.rb', line 65

def pretty_json_include
  # No-op - Stimulus controller handles loading
end

#pretty_json_tag(object, expand: 4) ⇒ String

Pretty Json wrapper using Stimulus controller for Turbo Frame compatibility
The Stimulus controller lazy-loads the pretty-json-custom-element library

Examples:

<%= pretty_json_tag(@image.asset) %>
<%= pretty_json_tag(some_hash, expand: 2) %>

Parameters:

  • object (Hash, String)

    JSON object or string to display

  • expand (Integer) (defaults to: 4)

    Number of levels to expand by default

Returns:

  • (String)

    HTML with Stimulus controller



48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'app/helpers/crm_helper.rb', line 48

def pretty_json_tag(object, expand: 4)
  return unless object

  json_string = object.is_a?(Hash) ? JSON.pretty_generate(object) : object.to_s

  tag.div(
    data: {
      controller: 'pretty-json',
      pretty_json_expand_value: expand
    }
  ) do
    tag.pre(json_string, data: { pretty_json_target: 'source' }, class: 'mb-0', style: 'display: none;')
  end
end

#product_line_image_row(context_object) ⇒ Object



69
70
71
72
73
74
75
# File 'app/helpers/crm_helper.rb', line 69

def product_line_image_row(context_object)
  res = []
  context_object.product_lines.each do |pl|
    res << (:p, pl.lineage_expanded)
  end
  res.present? ? res.compact.uniq.join('') : nil
end

#render_button_drop_down_options(links, options = nil) ⇒ Object



706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
# File 'app/helpers/crm_helper.rb', line 706

def render_button_drop_down_options(links, options = nil)
  return if links.blank?

  options = (options || {}).reverse_merge({ class: 'dropdown-menu' })
  if options.delete(:right_menu)
    options[:class] = "#{options[:class]} dropdown-menu-end"
    # options[:class] = ((options[:class] || '').split | ['']).join(' ')
  end
  (:div, **options) do
    links.map do |l|
      case l
      when Hash
        (l[:tag] || :h6, l[:content], class: l[:class].presence || 'dropdown-item-text', style: l[:style].presence || 'white-space:normal')
      when :separator
        (:div, '', class: 'dropdown-divider')
      else # parse link and append class
        begin
          doc = Nokogiri::HTML(l)
          doc.search('a', 'input', 'button').tap do |l2|
            l2.add_class('dropdown-item')
            l2.remove_class('btn')
          end.map(&:to_s).join.html_safe
        rescue StandardError
          l
        end
      end
    end.join.html_safe
  end
end

#render_combo_drop_down(links, options = {}) ⇒ Object

Render a bs3 combo split dropdown with a main action, pass an array of links



637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
# File 'app/helpers/crm_helper.rb', line 637

def render_combo_drop_down(links, options = {})
  return if links.blank?

  top_link = links.shift
  # Attempt parsing top link
  top_link_html = begin
    Nokogiri::HTML(top_link.to_s).css('a')[0]
  rescue StandardError
    nil
  end
  # Convert to a hash, this will allow inner link to properly render as the main link rather than as a nested link under the button which causes target click issues
  if top_link_html
    other_attributes = top_link_html.attributes.to_h.with_indifferent_access
    other_attributes.delete('href')
    top_link = { text: top_link_html.inner_html.html_safe, href: top_link_html['href'], attributes: other_attributes }
  end
  main_link_class = options[:main_link_class] || 'btn btn-outline-primary my-2 my-md-0'
  top_link_css_classes = ['btn', 'text-nowrap', main_link_class].compact
  top_link_css_classes << 'combo-main-link col' if links.size > 0
  if top_link.is_a?(Hash) && (text = top_link[:text]) && (href = top_link[:href])
    attributes = top_link[:attributes].merge({ class: top_link_css_classes.join(' '), style: options[:main_link_style] }).symbolize_keys
    top_link_render = link_to(text, href, **attributes)
  else
    top_link_render = (:button, top_link, class: top_link_css_classes.join(' '), style: options[:main_link_style])
  end
  if links.empty? # render a plain button
    top_link_render
  else
    drop_down_class = main_link_class.dup.gsub('btn-block', '').squish
    dropdown_options = options[:dropdown_options] || {}
    # dropdown_options[:right_menu] = true

    (:div, class: 'btn-group d-flex d-md-inline-flex') do
      top_link_render +
        (:button, class: "btn dropdown-toggle dropdown-toggle-split #{drop_down_class}", data: { 'bs-toggle': 'dropdown', flip: false, display: 'static' }, aria: { expanded: false }) do
          (:span, '', class: 'caret') +
            (:span, 'Toggle Dropdown', class: 'visually-hidden')
        end +
        render_button_drop_down_options(links, dropdown_options)
    end
  end
end

#render_material_alerts(material_alerts) ⇒ Object



736
737
738
739
740
# File 'app/helpers/crm_helper.rb', line 736

def render_material_alerts(material_alerts)
  return if material_alerts.blank?

  render(partial: '/shared/crm_material_alerts', locals: { material_alerts: material_alerts })
end

#render_primary_combo_drop_down(links, options = {}) ⇒ Object



628
629
630
631
632
633
634
# File 'app/helpers/crm_helper.rb', line 628

def render_primary_combo_drop_down(links, options = {})
  return if links.blank?

  options[:main_link_class] ||= 'btn-outline-primary'
  options[:dropdown_options] ||= { right_menu: true }
  render_combo_drop_down(links, options)
end

#render_simple_drop_down(links, options = {}) ⇒ Object

Render a bs5 drop down where the first option triggers the drop down.



681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
# File 'app/helpers/crm_helper.rb', line 681

def render_simple_drop_down(links, options = {})
  return if block_given?
  return if links.blank?

  main_link = links.shift
  main_link_class = options[:main_link_class] || 'btn btn-outline-primary my-2 my-md-0'
  main_link_style = options[:main_link_style]

  return tag.span(tag.strong(main_link, class: 'text-success'), class: 'border border-success p-2 rounded text-nowrap') if links.empty?

  dropdown_options = options[:dropdown_options] || {}
  dropdown_options[:right_menu] = true if dropdown_options[:right_menu].nil?

   :div, class: ['dropdown', options[:class]].join(' ') do
    if links.present?
      (:button, type: 'button', class: "btn dropdown-toggle #{main_link_class}", style: main_link_style, data: { 'bs-toggle': 'dropdown', flip: false, display: 'static' }, aria: { expanded: false }) do
        main_link.html_safe
      end +
        render_button_drop_down_options(links, dropdown_options)
    else
       :button, main_link, role: 'button', class: "btn #{main_link_class}"
    end
  end
end

Renders a tab link for navigation

  • inline_mode: Uses Bootstrap native tabs (for forms with all content rendered inline)
  • remote_href: Uses Turbo Frame lazy loading with real URLs


899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
# File 'app/helpers/crm_helper.rb', line 899

def render_tab_link(label, tab_id:, remote_href:, separator: false, inline_mode: false, first_tab: false, tab_class: nil)
  if separator.to_b
    # Render a divider section with a line and title
    (:div, class: 'd-flex align-items-center mt-4 mb-2') do
      concat (:div, '', class: 'border-top flex-grow-1')
      concat (:div, label, class: 'mx-3 text-muted small fw-bold text-center')
      concat (:div, '', class: 'border-top flex-grow-1')
    end
  elsif inline_mode
    # Bootstrap native tab switching for form-based tabs (all content rendered inline)
    link_to label, "##{tab_id}",
            class: ["nav-link px-2 text-nowrap", tab_class, ('active' if first_tab)].compact.join(' '),
            id: "#{tab_id}-tab-header",
            role: 'tab',
            aria: { controls: tab_id.to_s, selected: first_tab },
            data: {
              bs_toggle: 'pill',
              bs_target: "##{tab_id}"
            }
  else
    # Turbo Frame lazy loading with real URLs
    # - data-turbo-frame loads content into the tab-content frame
    # - data-turbo-action="advance" updates browser history
    # - Stimulus controller just manages active class
    link_to label, remote_href,
            class: ["nav-link px-2 text-nowrap", tab_class, ('active' if first_tab)].compact.join(' '),
            id: "#{tab_id}-tab-header",
            aria: { selected: first_tab },
            data: {
              turbo_frame: tab_frame_id,
              turbo_action: 'advance',
              action: 'turbo-tabs#activate',
              turbo_tabs_target: 'link'
            }
  end
end

#render_tab_panel(target, panel_open: false, remote_href: nil) ⇒ Object



945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
# File 'app/helpers/crm_helper.rb', line 945

def render_tab_panel(target, panel_open: false, remote_href: nil, &)
  (:div,
              class: "tab-pane fade #{'show active' if panel_open}",
              aria: {
                labelledby: "#{target}-tab-header"
              },
              role: 'tabpanel',
              id: target.to_s) do
    if remote_href
      # Append target_id without collapsing repeated params (arrays)
      begin
        uri = Addressable::URI.parse(remote_href)
        pairs = Addressable::URI.form_unencode(uri.query || '')
        has_target = pairs.any? { |(k, _v)| k == 'target_id' }
        pairs << ['target_id', target.to_s] unless has_target
        uri.query = Addressable::URI.form_encode(pairs)
        remote_href = uri.to_s
      rescue StandardError
        # Best-effort: fall back to naive append
        separator = remote_href.include?('?') ? '&' : '?'
        remote_href = "#{remote_href}#{separator}target_id=#{ERB::Util.url_encode(target.to_s)}"
      end

      # Pagination links are kept in-frame via data-turbo-frame on anchors where needed.
      turbo_frame_tag target, src: remote_href, loading: :lazy do
        (:div, class: 'text-center p-4') do
          (:div, class: 'spinner-border text-primary', role: 'status') do
            (:span, 'Loading...', class: 'visually-hidden')
          end
        end
      end
    elsif block_given?
      yield
    end
  end
end

#report_message_js(title, msg = nil, severity = :error) ⇒ Object

A helper method to generate the script tag for the reportError js bootstrap alert
severity: :error, :warning, :info, :success



989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
# File 'app/helpers/crm_helper.rb', line 989

def report_message_js(title, msg = nil, severity = :error)
  if msg.nil?
    msg = title
    title = severity.to_s.titleize
  end
  method = "report#{severity.to_s.titleize}".html_safe
  # Transform array automatically fro simplicity
  return if msg.blank?

  msg = msg.join('. ').html_safe if msg.respond_to?(:to_a)
  js = <<-EOS
   #{method}("#{title}", "#{msg}");
  EOS
  js.html_safe
end

#simple_list_panel(title = nil, options = {}) ⇒ Object



494
495
496
497
498
499
500
# File 'app/helpers/crm_helper.rb', line 494

def simple_list_panel(title = nil, options = {}, &)
  options.reverse_merge!({ class: 'card-body' })
  (:div, options) do
    (title ? (:h3, title.html_safe, class: 'card-title') : '').html_safe +
      (:ul, class: 'list-group', &)
  end
end

#simple_panel(title = nil, options = {}) ⇒ Object



477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
# File 'app/helpers/crm_helper.rb', line 477

def simple_panel(title = nil, options = {}, &)
  remove_panel_body = options.delete(:skip_body_wrap)
  panel_body_class = options.delete(:panel_body_class)
  options.reverse_merge!({ class: 'card w-100' })
  (:div, options) do
    out = []
    if title
      title = (:a, title.html_safe, name: options[:anchor_name]) if options[:anchor_name]
      out << (:div, (:span, title.html_safe, class: 'card-title h5'), class: "card-header #{options[:panel_header_class]}")
    end
    panel_body_class = "card-body #{panel_body_class}" unless remove_panel_body.to_b
    out << (:div, class: panel_body_class, &)

    out.join.html_safe
  end
end

#simple_panel_table(title = nil, options = {}) ⇒ Object



502
503
504
505
506
507
508
# File 'app/helpers/crm_helper.rb', line 502

def simple_panel_table(title = nil, options = {})
  options.reverse_merge!({ class: 'card-body' })
  (:div, **options) do
    concat (:h3, title, class: 'card-title') if title
    yield
  end
end

#simple_panel_value(title:, value:, collapse_limit: nil, display_empty: false) ⇒ Object



510
511
512
513
514
515
# File 'app/helpers/crm_helper.rb', line 510

def simple_panel_value(title:, value:, collapse_limit: nil, display_empty: false)
  return unless value.present? || display_empty

  open_panel = collapse_limit.nil? || value.size < collapse_limit
  panel(title, open_panel) { value }
end

#tab_panel(tab_hsh = {}) ⇒ Object

Renders a tab panel CRM style with vertical pills and deferred loading

Uses hash-based URLs (#tab-id) for clean, bookmarkable links:

  • Tab content is loaded on demand based on URL hash (no wasted requests)
  • Browser history works via hash changes (native back/forward support)
  • In-tab navigation (forms, pagination) uses data-turbo-frame with tab_frame_id

tabs is a hash where keys are tab IDs and values are option hashes:

  • :title - Display title (defaults to tab_id.titleize)
  • :remote_href - URL to load tab content from
  • :counter - Badge count to display
  • :counter_html - Custom HTML for badge
  • :separator - If true, renders as a section divider instead of a tab
  • :partial / :locals - For non-remote tabs (legacy)

Options (passed via :_options key):

  • :sticky_sidebar - Make sidebar sticky on scroll
  • :sticky_offset - CSS top offset for sticky sidebar
  • :sidebar_extra_html - Additional HTML to render in sidebar


761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
# File 'app/helpers/crm_helper.rb', line 761

def tab_panel(tab_hsh = {})
  options = tab_hsh.delete(:_options) || {}
  tab_hsh.each do |tab_id, tab_options|
    tab_options[:title] ||= tab_id.to_s.titleize
    tab_options[:title_html] = (:div, class: 'd-flex') do
      concat (:div, tab_options[:title], class: 'd-flex flex-grow me-auto')
      concat (:div, counter_badge(tab_options[:counter]), class: 'd-flex') if tab_options[:counter] && (tab_options[:counter] > 0)
      concat (:div, tab_options[:counter_html].html_safe, class: 'd-flex') if tab_options[:counter_html].present?
    end
  end

  # Default to first tab unless params[:tab] names a real tab. The query
  # param is the server-authoritative way to select which tab loads on
  # initial render, so a redirect like /warehouses/1?tab=picking#picking
  # delivers the right tab without depending on JavaScript to reconcile.
  available_tabs = tab_hsh.keys
  requested_tab = params[:tab].to_s
  initial_tab_id = if requested_tab.present? && available_tabs.map(&:to_s).include?(requested_tab)
                     requested_tab
                   else
                     available_tabs.first.to_s
                   end
  default_tab_id = initial_tab_id

  # Check if all tabs use partials (inline mode for forms - no Turbo loading)
  all_inline = tab_hsh.values.all? { |opts| opts[:partial].present? && opts[:remote_href].blank? }

  # Wrap in Stimulus controller for hash-based navigation
  wrapper_data = if all_inline
                   # Use tab-persist for inline tabs to preserve tab state in URL hash
                   { controller: 'tab-persist' }
                 else
                   { controller: 'turbo-tabs', turbo_tabs_default_tab_value: default_tab_id }
                 end

  (:div,
              class: 'navbar navbar-expand-md align-items-start p-0',
              data: wrapper_data) do
    (:button, type: 'button', id: 'btn-tabs-toggler', class: 'd-md-none btn btn-outline-primary btn-block py-3', data: { 'bs-toggle': 'collapse', 'bs-target': '#navbarTabs' }, aria: { expanded: 'collapse', controls: 'navbarTabs' }) do
      (:span, '', class: 'fa fa-bars pe-2') +
        (:span, 'Menu', class: '')
    end +
      (:div, class: 'row w-100 g-0 flex-md-nowrap') do
        (:div,
                    class: [
                      'col-12 col-md-auto pe-md-3',
                      (options[:sticky_sidebar] ? 'position-sticky sticky-top align-self-start' : nil)
                    ].compact.join(' '),
                    data: { turbo_tab_sidebar: '' },
                    style: (options[:sticky_sidebar] ? (options[:sticky_offset] || 'top: 64px') : nil)) do
          (:div, class: 'collapse navbar-collapse', id: 'navbarTabs') do
            (:span, 'Menu', class: 'h4 d-block d-md-none fw-bold p-3 mb-0') +
              (:button, type: 'button', id: 'btn-tabs-close', class: 'd-md-none btn btn-sm btn-primary rounded-circle', data: { 'bs-toggle': 'collapse', 'bs-target': '#navbarTabs' },
                                   aria: { expanded: 'collapse', controls: 'navbarTabs' }) do
                (:span, '', class: 'fa fa-times')
              end +
              (:div, id: "tab-nav-#{controller_name}", class: 'tab-sidebar') do
                extra = ''.html_safe
                extra = (:div, options[:sidebar_extra_html].html_safe, class: 'card p-2 mb-2') if options[:sidebar_extra_html].present?
                # Check if all tabs use partials (inline mode for forms)
                all_inline = tab_hsh.values.all? { |opts| opts[:partial].present? && opts[:remote_href].blank? }
                extra +
                  (:div, class: 'nav flex-column nav-pills card p-2 my-0',
                                    id: 'v-pills-tab',
                                    data: (all_inline ? {} : { turbo_tabs_target: 'nav' })) do
                    content = []
                    tab_hsh.each_with_index do |(tab_id, tab_options), index|
                      is_active = all_inline ? index.zero? : (tab_id.to_s == initial_tab_id)
                      content << render_tab_link(
                        tab_options[:title_html],
                        tab_id: tab_id,
                        remote_href: tab_options[:remote_href],
                        separator: tab_options[:separator],
                        inline_mode: all_inline,
                        first_tab: is_active,
                        tab_class: tab_options[:tab_class]
                      )
                    end
                    content.join.html_safe
                  end
              end
          end
        end +
          # Single turbo-frame for all tab content (loaded by Stimulus controller)
          (:div, class: 'col-12 col-md mt-3 mt-md-0 d-flex') do
            (:div, class: 'tab-content card p-2 w-100', id: 'v-pills-tabContent') do
              # Check if all tabs use partials (inline mode for forms)
              all_inline = tab_hsh.values.all? { |opts| opts[:partial].present? && opts[:remote_href].blank? }

              if all_inline
                # Render all tab content inline for form-based tabs
                # Uses Bootstrap's native tab switching (no Turbo loading)
                tab_hsh.each_with_index do |(tab_id, tab_options), index|
                  concat (:div,
                                     class: "tab-pane fade #{'show active' if index.zero?}",
                                     id: tab_id.to_s,
                                     role: 'tabpanel',
                                     aria: { labelledby: "#{tab_id}-tab-header" }) {
                    render partial: tab_options[:partial],
                           locals: tab_options[:locals] || {}
                  }
                end
              else
                # Turbo Frame lazy loading - content loaded via src attribute.
                # Use initial_tab_id (server-resolved from params[:tab]) so a
                # redirect to /resource?tab=foo#foo renders the right tab on
                # the first paint, no JS reconciliation required.
                initial_tab_options = tab_hsh[initial_tab_id.to_sym] || tab_hsh[initial_tab_id] || tab_hsh[available_tabs.first]
                initial_tab_url = initial_tab_options[:remote_href]

                # If chosen tab has a partial (no remote_href), render it inline.
                # Otherwise, load via src attribute.
                # The global `turbo:frame-missing` listener (in
                # client/js/crm/setup/turbo_config.js) keys off
                # data-turbo-tabs-target="frame" to contain failures inside
                # the pane (e.g. when a tab href returns full layout HTML).
                if initial_tab_options[:partial].present? && initial_tab_url.blank?
                  turbo_frame_tag tab_frame_id,
                                  data: { turbo_tabs_target: 'frame' } do
                    render partial: initial_tab_options[:partial],
                           locals: initial_tab_options[:locals] || {}
                  end
                else
                  # Loading state is handled by CSS via turbo-frame[busy] selector
                  turbo_frame_tag tab_frame_id,
                                  src: initial_tab_url,
                                  data: { turbo_tabs_target: 'frame' }
                end
              end
            end
          end
      end
  end
end

#tab_should_be_open?(tab_id, index) ⇒ Boolean

Returns:

  • (Boolean)


982
983
984
985
# File 'app/helpers/crm_helper.rb', line 982

def tab_should_be_open?(tab_id, index)
  (params[:tab].present? && params[:tab] == tab_id.to_s) ||
    (params[:tab].blank? && index == 0)
end

#text_only(raw_input, options = {}) ⇒ Object



237
238
239
240
241
242
243
244
245
246
247
# File 'app/helpers/crm_helper.rb', line 237

def text_only(raw_input, options = {})
  options[:length] || 1000
  text = raw_input
  begin
    text = Nokogiri::HTML(text).text
    text = truncate(d.to_s, length: length, omission: '...')
  rescue StandardError
    # Just return as is
  end
  text
end

#time_collection_for_select(increments = 900) ⇒ Object



444
445
446
447
448
449
450
451
452
453
# File 'app/helpers/crm_helper.rb', line 444

def time_collection_for_select(increments = 900)
  start_time = Time.zone.parse('12:00 am')
  end_time = Time.zone.parse('11:59 pm')
  res = []
  loop do
    res << start_time.strftime('%I:%M %P')
    break unless (start_time += increments) < end_time
  end
  res
end

#timezone_abbreviated(tz_name) ⇒ Object



1088
1089
1090
1091
1092
1093
1094
# File 'app/helpers/crm_helper.rb', line 1088

def timezone_abbreviated(tz_name)
  return if tz_name.blank?

  tz = TZInfo::Timezone.get(tz_name)
  tz.strftime('%Z')
rescue StandardError
end

#traffic_badge_class(traffic) ⇒ String

Badge class for SEO traffic values

Parameters:

  • traffic (Integer)

    Monthly organic traffic estimate

Returns:

  • (String)

    Bootstrap badge class



1099
1100
1101
1102
1103
1104
1105
1106
# File 'app/helpers/crm_helper.rb', line 1099

def traffic_badge_class(traffic)
  case traffic
  when 1000.. then 'bg-success'
  when 100..999 then 'bg-primary'
  when 10..99 then 'bg-secondary'
  else 'bg-light text-dark'
  end
end

#truncate_array_for_display(array, limit: 5, separator: '<br>', item_limit: 20) ⇒ Object



14
15
16
17
18
19
20
21
22
23
24
# File 'app/helpers/crm_helper.rb', line 14

def truncate_array_for_display(array, limit: 5, separator: '<br>', item_limit: 20)
  processed_array = array.map(&:to_s)
  processed_array = processed_array.map { |e| e.truncate(item_limit) } if item_limit.present?
  if processed_array.size > limit
    truncated = processed_array.first(limit)
    remaining_count = processed_array.size - limit
    truncated.join('<br>').html_safe + "<br>+#{remaining_count} more ...".html_safe
  else
    processed_array.map(&:to_s).join('<br>').html_safe
  end
end

#turbo_stream_activate_tab(tab_id) ⇒ Object

Generates Turbo Stream actions to update the active tab in the navigation
Include this at the top of tab templates to update active state from the server
Usage: <%= turbo_stream_activate_tab(:basic_attributes) %>



939
940
941
942
943
# File 'app/helpers/crm_helper.rb', line 939

def turbo_stream_activate_tab(tab_id)
  # Remove active class from all tab links, add to the current one
  turbo_stream.remove_css_class('.nav-link.active[data-turbo-tabs-target="link"]', 'active') +
    turbo_stream.add_css_class("##{tab_id}-tab-header", 'active')
end

#verification_badge(check_value) ⇒ Object



1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
# File 'app/helpers/crm_helper.rb', line 1108

def verification_badge(check_value)
  case check_value
  when 'pass'
    (:span, class: 'text-success') do
      fa_icon('circle-check', text: 'Pass')
    end
  when 'fail'
    (:span, class: 'text-danger') do
      fa_icon('circle-xmark', text: 'Failed')
    end
  when 'unavailable'
    (:span, 'Unavailable', class: 'text-muted')
  when 'unchecked'
    (:span, 'Not checked', class: 'text-muted')
  else
    (:span, check_value&.titleize || '', class: 'text-muted')
  end
end