Module: CrmHelper

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

Overview

View helper: crm.

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



566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
# File 'app/helpers/crm_helper.rb', line 566

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



1111
1112
1113
1114
1115
1116
1117
# File 'app/helpers/crm_helper.rb', line 1111

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.



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'app/helpers/crm_helper.rb', line 227

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



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'app/helpers/crm_helper.rb', line 142

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



193
194
195
196
197
198
199
# File 'app/helpers/crm_helper.rb', line 193

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



270
271
272
273
274
275
276
277
278
279
# File 'app/helpers/crm_helper.rb', line 270

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



394
395
396
397
398
399
400
401
402
403
# File 'app/helpers/crm_helper.rb', line 394

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


405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# File 'app/helpers/crm_helper.rb', line 405

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.



446
447
448
449
450
451
452
453
454
455
456
457
# File 'app/helpers/crm_helper.rb', line 446

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. If the versions DB is
  # momentarily unavailable, fall through to creator_id rather than 500 the page that
  # renders this audit widget (AppSignal #1745).
  create_version = RecordVersionBase.safe_read { 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



519
520
521
# File 'app/helpers/crm_helper.rb', line 519

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



1105
1106
1107
1108
1109
# File 'app/helpers/crm_helper.rb', line 1105

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



41
42
43
44
45
46
# File 'app/helpers/crm_helper.rb', line 41

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



48
49
50
# File 'app/helpers/crm_helper.rb', line 48

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)



20
21
22
23
24
25
26
27
# File 'app/helpers/crm_helper.rb', line 20

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


1130
1131
1132
1133
1134
1135
1136
# File 'app/helpers/crm_helper.rb', line 1130

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



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

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



463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# File 'app/helpers/crm_helper.rb', line 463

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


1138
1139
1140
1141
1142
1143
1144
1145
# File 'app/helpers/crm_helper.rb', line 1138

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



512
513
514
515
516
517
# File 'app/helpers/crm_helper.rb', line 512

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



121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'app/helpers/crm_helper.rb', line 121

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


1097
1098
1099
# File 'app/helpers/crm_helper.rb', line 1097

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


1101
1102
1103
# File 'app/helpers/crm_helper.rb', line 1101

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


1064
1065
1066
1067
1068
1069
1070
1071
1072
# File 'app/helpers/crm_helper.rb', line 1064

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


1085
1086
1087
1088
1089
# File 'app/helpers/crm_helper.rb', line 1085

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


1091
1092
1093
1094
1095
# File 'app/helpers/crm_helper.rb', line 1091

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


1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
# File 'app/helpers/crm_helper.rb', line 1074

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



247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'app/helpers/crm_helper.rb', line 247

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.



1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
# File 'app/helpers/crm_helper.rb', line 1188

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.compact_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



1120
1121
1122
1123
1124
1125
1126
1127
1128
# File 'app/helpers/crm_helper.rb', line 1120

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



293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
# File 'app/helpers/crm_helper.rb', line 293

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]
  if link_extra.blank?
    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



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
627
628
629
630
631
632
633
634
635
636
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
# File 'app/helpers/crm_helper.rb', line 586

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
        tag.div(class: 'dropdown') do
          tag.button(fa_icon(:wrench),
                     type: 'button',
                     class: dropdown_toggle_class,
                     data: { 'bs-toggle': 'dropdown', 'bs-flip': false },
                     aria: { haspopup: true, expanded: false }) +
            tag.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



486
487
488
# File 'app/helpers/crm_helper.rb', line 486

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



110
111
112
# File 'app/helpers/crm_helper.rb', line 110

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

Render object as a collapsible JSON tree via pretty-json-custom-element
when the content is JSON (Hash, Array, or a string that parses as JSON).
For Ruby Hash#inspect strings (legacy shipping_api_log rows persisted
before commit a16f53b528 fixed ShipEngine carriers to store Hashes),
convert to JSON via Heatwave::RubyHashInspectConverter so they render
as a tree retroactively without any backfill. Everything else (XML
payloads from RLCarriers, plain text) falls back to a verbatim
block so the user still sees the body instead of an empty panel.

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



71
72
73
74
75
76
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 71

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

  json_string =
    case object
    when Hash, Array
      JSON.pretty_generate(object)
    else
      string = object.to_s
      if (parsed = try_parse_json_or_ruby_inspect(string))
        parsed
      else
        # Truly not parseable — render verbatim, wrapped, in a <pre>.
        return tag.pre(string, class: 'mb-0 small', style: 'white-space: pre-wrap; word-break: break-word;')
      end
    end

  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



114
115
116
117
118
119
# File 'app/helpers/crm_helper.rb', line 114

def product_line_image_row(context_object)
  res = context_object.product_lines.map do |pl|
    (:p, pl.lineage_expanded)
  end
  res.present? ? res.compact.uniq.join : nil
end

#render_button_drop_down_options(links, options = nil) ⇒ Object



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
# File 'app/helpers/crm_helper.rb', line 765

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



685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
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 685

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' unless links.empty?
  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
    # type: 'button' is required: a <button> with no type defaults to
    # type="submit" inside a <form>, so clicking this combo's main button
    # while it's nested inside e.g. the warehouse mass-action form would
    # silently submit that form (with no selected_action) and surface as
    # "Mass action not recognized" or "Couldn't find Delivery with id=1".
    top_link_render = tag.button(top_link, type: 'button', 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

    tag.div(class: 'btn-group d-flex d-md-inline-flex') do
      top_link_render +
        # type: 'button' on the dropdown caret: same reason as above —
        # without it the caret defaults to type=submit and clicking it to
        # open the menu accidentally submits any enclosing <form>.
        tag.button(type: 'button', class: "btn dropdown-toggle dropdown-toggle-split #{drop_down_class}", data: { 'bs-toggle': 'dropdown', flip: false, display: 'static' }, aria: { expanded: false }) do
          tag.span('', class: 'caret') +
            tag.span('Toggle Dropdown', class: 'visually-hidden')
        end +
        render_button_drop_down_options(links, dropdown_options)
    end
  end
end

#render_material_alerts(material_alerts) ⇒ Object



795
796
797
798
799
# File 'app/helpers/crm_helper.rb', line 795

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



676
677
678
679
680
681
682
# File 'app/helpers/crm_helper.rb', line 676

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.



737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
# File 'app/helpers/crm_helper.rb', line 737

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?

  tag.div(class: ['dropdown', options[:class]].join(' ')) do
    if links.present?
      tag.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
      # type: 'button' for the same reason as render_combo_drop_down: a
      # <button> with no type defaults to submit inside a <form> and would
      # silently trip enclosing forms.
      tag.button(main_link, type: 'button', 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


958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
# File 'app/helpers/crm_helper.rb', line 958

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



1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
# File 'app/helpers/crm_helper.rb', line 1004

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



1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
# File 'app/helpers/crm_helper.rb', line 1048

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



540
541
542
543
544
545
546
# File 'app/helpers/crm_helper.rb', line 540

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



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

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



548
549
550
551
552
553
554
# File 'app/helpers/crm_helper.rb', line 548

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



556
557
558
559
560
561
# File 'app/helpers/crm_helper.rb', line 556

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

#sunny_monthly_budgetAssistant::MonthlyBudget?

Sunny monthly spend budget for the current user — for the chat Settings
panel. nil when there is no signed-in user (e.g. error pages). Memoized.

Returns:



8
9
10
11
12
13
14
15
# File 'app/helpers/crm_helper.rb', line 8

def sunny_monthly_budget
  return if current_user.blank?

  @sunny_monthly_budget ||= Assistant::MonthlyBudget.new(
    party_id: current_user.id,
    manager: &.is_manager? || false
  )
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


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
895
896
897
898
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
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
# File 'app/helpers/crm_helper.rb', line 820

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)


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

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



281
282
283
284
285
286
287
288
289
290
291
# File 'app/helpers/crm_helper.rb', line 281

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



490
491
492
493
494
495
496
497
498
499
# File 'app/helpers/crm_helper.rb', line 490

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



1147
1148
1149
1150
1151
1152
1153
# File 'app/helpers/crm_helper.rb', line 1147

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



1158
1159
1160
1161
1162
1163
1164
1165
# File 'app/helpers/crm_helper.rb', line 1158

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



29
30
31
32
33
34
35
36
37
38
39
# File 'app/helpers/crm_helper.rb', line 29

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

#try_parse_json_or_ruby_inspect(string) ⇒ Object

Try string as JSON, then as Ruby Hash#inspect output. Returns a
JSON string suitable for feeding pretty-json-custom-element, or
nil if neither shape parses.



101
102
103
104
105
106
# File 'app/helpers/crm_helper.rb', line 101

def try_parse_json_or_ruby_inspect(string)
  JSON.parse(string)
  string
rescue JSON::ParserError
  Heatwave::RubyHashInspectConverter.call(string)
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) %>



998
999
1000
1001
1002
# File 'app/helpers/crm_helper.rb', line 998

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



1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
# File 'app/helpers/crm_helper.rb', line 1167

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