Module: CrmHelper

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

Overview

View helper: crm.

Constant Summary collapse

PANEL_OPTION_KEYS =

Keys #render_panel consumes; anything left over becomes an HTML attribute
on the card, which is how simple_panel has always behaved.

%i[
  icon collapsible open id anchor_name class panel_class add_class panel_size
  header_class panel_header_outer_class title_class panel_body_class
  skip_title_span skip_body_wrap href mode link link_label actions trash
  menu_icon dropdown_style dropdown_toggle_class collapsed
].freeze

Instance Method Summary collapse

Instance Method Details

#alert(options = {}) ⇒ ActiveSupport::SafeBuffer

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

Parameters:

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

    alert configuration

Options Hash (options):

  • severity (String)

    Bootstrap alert variant ('info', 'danger', 'warning', 'primary'; 'alert' maps to 'warning', 'notice' to 'primary'; defaults to 'danger')

  • content (String, nil)

    alert body HTML (a block may be given instead)

  • dismissible (Boolean)

    render a close button (defaults to true)

Returns:

  • (ActiveSupport::SafeBuffer)

    the rendered alert HTML



597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
# File 'app/helpers/crm_helper.rb', line 597

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 = {}) ⇒ ActiveSupport::SafeBuffer

Renders an array of values as a <ul> list.

Parameters:

  • values (Array)

    the list items

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

    html options forwarded to the <ul> content_tag

Options Hash (options):

  • class (String, nil)

    CSS classes for the <ul>

Returns:

  • (ActiveSupport::SafeBuffer)

    the list HTML



1236
1237
1238
1239
1240
1241
1242
# File 'app/helpers/crm_helper.rb', line 1236

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 cell.
  • value_style: Inline style string applied to the value cell.
  • 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.
  • Consecutive rows in one container share a label column (see
    _panels.scss), so labels stay aligned and only wrap when they genuinely
    run out of room — the fixed col-4/col-8 split this replaced wrapped
    values while the card still had empty space to give.

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.

Options Hash (options):

  • format (Symbol, nil)

    formatter to apply (:date, :datetime, :currency, :link); nil infers from the value type

  • prefix (String, nil)

    string prepended to the formatted value

  • suffix (String, nil)

    string appended to the formatted value

  • value_extra_css_class (String, nil)

    extra CSS classes applied to the value cell

  • value_style (String, nil)

    inline style string applied to the value cell

  • tooltip (String, nil)

    sets the title attribute on the value cell (simple browser tooltip)

  • tooltip_html (Boolean)

    when true, also sets data-bs-toggle="tooltip" (Bootstrap tooltip opt-in)



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

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: 'attr-row') do
    concat (:div, raw("#{attr_label}:"), class: 'attr-row__label')
    value_attrs = {
      class: class_names('attr-row__value', 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) ⇒ ActiveSupport::SafeBuffer, ...

Formats a raw attribute value for display in an #attr_display row.

Parameters:

  • attr_value (Object)

    the raw value to format

  • options (Hash)

    formatting options

Options Hash (options):

  • format (Symbol, nil)

    formatter to apply (:date, :datetime, :currency, :link); nil infers from the value type

  • prefix (String, nil)

    string prepended to the formatted value

  • suffix (String, nil)

    string appended to the formatted value

Returns:

  • (ActiveSupport::SafeBuffer, String, nil)

    the formatted value, or nil when blank



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
192
193
194
195
196
197
198
199
200
201
# File 'app/helpers/crm_helper.rb', line 152

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



203
204
205
206
207
208
209
# File 'app/helpers/crm_helper.rb', line 203

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

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



419
420
421
422
423
424
425
426
427
428
# File 'app/helpers/crm_helper.rb', line 419

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


430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
# File 'app/helpers/crm_helper.rb', line 430

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.



471
472
473
474
475
476
477
478
479
480
481
482
# File 'app/helpers/crm_helper.rb', line 471

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



544
545
546
# File 'app/helpers/crm_helper.rb', line 544

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 = {}) ⇒ ActiveSupport::SafeBuffer

Renders a horizontally centered flex row.

Parameters:

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

    display options

Options Hash (options):

  • class (String, nil)

    extra CSS classes on the wrapping div

  • content (String, nil)

    inner HTML; a block may be given instead

Returns:

  • (ActiveSupport::SafeBuffer)

    the row HTML



1224
1225
1226
1227
1228
# File 'app/helpers/crm_helper.rb', line 1224

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



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

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



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

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)



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

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

Renders a delete link for a resource, gated on :destroy permission.

Parameters:

  • resource (Object)

    the record to delete

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

    link options

Options Hash (options):

  • skip_permission_check (Boolean)

    skip the can?(:destroy, ...) check

  • delete_path (String, nil)

    override the DELETE URL (defaults to the resource's polymorphic path)

  • destroy (Object)

    extra routing param forwarded to polymorphic_path (e.g. a nested destroy target)

Returns:

  • (ActiveSupport::SafeBuffer, nil)

    the link HTML, or nil without permission



1263
1264
1265
1266
1267
1268
1269
# File 'app/helpers/crm_helper.rb', line 1263

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



526
527
528
529
530
531
532
533
534
535
# File 'app/helpers/crm_helper.rb', line 526

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



488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
# File 'app/helpers/crm_helper.rb', line 488

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

Renders an edit link for a resource, gated on :update permission.

Parameters:

  • resource (Object)

    the record to edit

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

    link options

Options Hash (options):

  • edit_path (String, nil)

    override the edit URL (defaults to the resource's polymorphic edit path)

Returns:

  • (ActiveSupport::SafeBuffer, nil)

    the link HTML, or nil without permission



1277
1278
1279
1280
1281
1282
1283
1284
# File 'app/helpers/crm_helper.rb', line 1277

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



537
538
539
540
541
542
# File 'app/helpers/crm_helper.rb', line 537

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



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

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


1210
1211
1212
# File 'app/helpers/crm_helper.rb', line 1210

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


1214
1215
1216
# File 'app/helpers/crm_helper.rb', line 1214

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


1177
1178
1179
1180
1181
1182
1183
1184
1185
# File 'app/helpers/crm_helper.rb', line 1177

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


1198
1199
1200
1201
1202
# File 'app/helpers/crm_helper.rb', line 1198

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


1204
1205
1206
1207
1208
# File 'app/helpers/crm_helper.rb', line 1204

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


1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
# File 'app/helpers/crm_helper.rb', line 1187

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



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'app/helpers/crm_helper.rb', line 268

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.



1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
# File 'app/helpers/crm_helper.rb', line 1327

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



1245
1246
1247
1248
1249
1250
1251
1252
1253
# File 'app/helpers/crm_helper.rb', line 1245

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 = {}) ⇒ ActiveSupport::SafeBuffer?

Renders the CRM pagination bar (Bootstrap nav, info tag, per-page selector).

Parameters:

  • pagy (Pagy, nil) (defaults to: @pagy)

    the pagy instance to render

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

    display options

Options Hash (options):

  • always_show_info (Boolean)

    force the info section even with one page (defaults to true)

  • id (String, nil)

    DOM id of the wrapping div (defaults to 'paginate_bar')

  • per_page_options (Array<Integer>, nil)

    allowed per-page values; renders the per-page selector

  • link_extra (String, nil)

    extra HTML attributes injected into pagination links (defaults to a data-turbo-frame attribute targeting the current Turbo Frame)

Returns:

  • (ActiveSupport::SafeBuffer, nil)

    the pagination bar HTML, or nil when nothing to show



318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
# File 'app/helpers/crm_helper.rb', line 318

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-body-secondary 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 = {}) ⇒ ActiveSupport::SafeBuffer

Collapsible CRM panel — same markup as #simple_panel plus a collapse
toggle on the title. Thin wrapper over Crm::PanelComponent; see
#render_panel for the shared option surface.

Parameters:

  • title (String, nil)

    header text

  • panel_open (Boolean) (defaults to: true)

    initial collapse state (falsy renders collapsed)

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

    panel options; :panel_header_class lands on the
    title element here (it means "header classes" in #simple_panel)

Options Hash (options):

  • panel_header_class (String)

    CSS classes for the title element

  • collapsed (Boolean)

    render the panel collapsed (legacy alias for panel_open = false)

Returns:

  • (ActiveSupport::SafeBuffer)


626
627
628
629
630
631
632
633
634
635
636
637
638
# File 'app/helpers/crm_helper.rb', line 626

def panel(title, panel_open = true, options = {}, &)
  if panel_open.is_a?(Hash)
    # `panel('X', icon: 'y')` binds the whole kwargs hash to panel_open, not
    # options — fold it back or those options are silently dropped.
    options = panel_open.merge(options.is_a?(Hash) ? options : {})
    panel_open = true
  else
    options = options.is_a?(Hash) ? options.dup : {}
  end
  panel_open = false if options.delete(:collapsed).to_b
  options[:title_class] = options.delete(:panel_header_class) if options.key?(:panel_header_class)
  render_panel(title, options.merge(collapsible: true, open: panel_open != false), &)
end

#possible_events(obj) ⇒ Object



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

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



112
113
114
# File 'app/helpers/crm_helper.rb', line 112

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

Usage:

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

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.

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



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

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
      return tag.pre(string, class: 'mb-0 small', style: 'white-space: pre-wrap; word-break: break-word;') unless (parsed = try_parse_json_or_ruby_inspect(string))

      parsed

        # Truly not parseable — render verbatim, wrapped, in a <pre>.

    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



116
117
118
119
120
121
# File 'app/helpers/crm_helper.rb', line 116

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) ⇒ ActiveSupport::SafeBuffer?

Renders the dropdown menu entries for a button drop down.

Parameters:

  • links (Array<String, Hash, Symbol>)

    entries; Hashes render as text items, :separator renders a divider, anything else is parsed as link HTML

  • options (Hash, nil) (defaults to: nil)

    html options forwarded to the wrapping content_tag, plus:

Options Hash (options):

  • class (String, nil)

    CSS classes for the menu (defaults to 'dropdown-menu')

  • right_menu (Boolean)

    when true, right-aligns the menu (adds 'dropdown-menu-end'; defaults to true)

Returns:

  • (ActiveSupport::SafeBuffer, nil)

    the menu HTML



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

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 = {}) ⇒ ActiveSupport::SafeBuffer?

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

Parameters:

  • links (Array<String, Hash>)

    link HTML strings or hashes, first entry is the main action

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

    dropdown configuration

Options Hash (options):

  • main_link_class (String)

    CSS classes for the main link

  • main_link_style (String, nil)

    inline style for the main link

  • dropdown_options (Hash)

Returns:

  • (ActiveSupport::SafeBuffer, nil)

    the rendered dropdown HTML



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

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



907
908
909
910
911
# File 'app/helpers/crm_helper.rb', line 907

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 = {}) ⇒ ActiveSupport::SafeBuffer?

Renders a combo dropdown whose main link uses the primary button style.

Parameters:

  • links (Array<String, Hash>)

    link HTML strings or hashes, first entry is the main action

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

Options Hash (options):

  • main_link_class (String)

    CSS classes for the main link (defaults to 'btn-outline-primary')

  • dropdown_options (Hash)

    options for #render_button_drop_down_options (defaults to { right_menu: true })

Returns:

  • (ActiveSupport::SafeBuffer, nil)

    the rendered dropdown HTML



767
768
769
770
771
772
773
# File 'app/helpers/crm_helper.rb', line 767

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 = {}) ⇒ ActiveSupport::SafeBuffer?

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

Parameters:

  • links (Array<String, Hash>)

    link HTML entries; the first becomes the toggle button

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

    display options

Options Hash (options):

  • class (String, nil)

    extra CSS classes on the wrapping .dropdown div

  • main_link_class (String, nil)

    CSS classes for the toggle button (defaults to 'btn btn-outline-primary my-2 my-md-0')

  • main_link_style (String, nil)

    inline style for the toggle button

  • dropdown_options (Hash, nil)

    options forwarded to #render_button_drop_down_options

Returns:

  • (ActiveSupport::SafeBuffer, nil)

    the dropdown HTML



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

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


1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
# File 'app/helpers/crm_helper.rb', line 1071

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-body-secondary 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



1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
# File 'app/helpers/crm_helper.rb', line 1117

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



1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
# File 'app/helpers/crm_helper.rb', line 1161

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_panel(title = nil, options = {}) ⇒ ActiveSupport::SafeBuffer

Static CRM panel — a card whose body is always visible.

Thin wrapper over Crm::PanelComponent; see #render_panel for the shared
option surface. :panel_header_class lands on the .card-header here (it
means "title classes" in #panel — the legacy split, kept as-is).

Parameters:

  • title (String, nil) (defaults to: nil)

    header text; nil renders a header-less card

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

    panel options; any remaining keys are forwarded to #render_panel

Options Hash (options):

  • panel_header_class (String, nil)

    CSS classes for the .card-header (mapped to :header_class)

  • collapsed (Boolean)

    when true, renders the panel collapsed (collapsible: true, open: false)

Returns:

  • (ActiveSupport::SafeBuffer)


559
560
561
562
563
564
565
566
567
568
# File 'app/helpers/crm_helper.rb', line 559

def simple_panel(title = nil, options = {}, &)
  options = options.dup
  options[:header_class] = options.delete(:panel_header_class) if options.key?(:panel_header_class)
  # `collapsed: true` was a no-op on the old static panel — honour the intent.
  if options.delete(:collapsed).to_b
    options[:collapsible] = true
    options[:open] = false
  end
  render_panel(title, options.reverse_merge(collapsible: false), &)
end

#simple_panel_table(title = nil, options = {}) ⇒ ActiveSupport::SafeBuffer

Titled panel wrapping a table. Kept as its own name for the 5 call sites,
but it's #simple_panel — the table supplies its own padding.

Parameters:

  • title (String, nil) (defaults to: nil)

    header text

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

    panel options, see #render_panel

Options Hash (options):

  • panel_body_class (String)

    body CSS classes (defaults to 'p-0' here, the table supplies its own padding)

  • collapsible (Boolean)

    whether the panel can be collapsed

Returns:

  • (ActiveSupport::SafeBuffer)


578
579
580
# File 'app/helpers/crm_helper.rb', line 578

def simple_panel_table(title = nil, options = {}, &)
  simple_panel(title, options.reverse_merge(panel_body_class: 'p-0'), &)
end

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



582
583
584
585
586
587
# File 'app/helpers/crm_helper.rb', line 582

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:



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

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


932
933
934
935
936
937
938
939
940
941
942
943
944
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
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
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
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
# File 'app/helpers/crm_helper.rb', line 932

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-lg align-items-start p-0',
              data: wrapper_data) do
    (:button, type: 'button', id: 'btn-tabs-toggler', class: 'd-lg-none btn btn-outline-primary btn-block py-3', data: { 'bs-toggle': 'collapse', 'bs-target': '#navbarTabs' }, aria: { expanded: 'collapse', controls: 'navbarTabs' }) do
      fa_icon('bars', class: 'pe-2') +
        (:span, 'Menu', class: '')
    end +
      (:div, class: 'row w-100 g-0 flex-lg-nowrap') do
        (:div,
                    class: [
                      'col-12 col-lg-auto pe-lg-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-lg-none fw-bold p-3 mb-0') +
              (:button, type: 'button', id: 'btn-tabs-close', class: 'd-lg-none btn btn-sm btn-primary rounded-circle', data: { 'bs-toggle': 'collapse', 'bs-target': '#navbarTabs' },
                                   aria: { expanded: 'collapse', controls: 'navbarTabs' }) do
                fa_icon('xmark')
              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-lg mt-3 mt-lg-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
                # app/javascript/turbo_stream_actions.js, imported by
                # 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)


1154
1155
1156
1157
# File 'app/helpers/crm_helper.rb', line 1154

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 = {}) ⇒ String

Strips HTML markup and truncates a string for plain-text display.

Parameters:

  • raw_input (String)

    the HTML-containing input

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

    truncation options

Options Hash (options):

  • length (Integer)

    maximum length before truncation (defaults to 1000)

Returns:

  • (String)

    the plain-text result



297
298
299
300
301
302
303
304
305
306
307
# File 'app/helpers/crm_helper.rb', line 297

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



515
516
517
518
519
520
521
522
523
524
# File 'app/helpers/crm_helper.rb', line 515

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



1286
1287
1288
1289
1290
1291
1292
# File 'app/helpers/crm_helper.rb', line 1286

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



1297
1298
1299
1300
1301
1302
1303
1304
# File 'app/helpers/crm_helper.rb', line 1297

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



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

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.



103
104
105
106
107
108
# File 'app/helpers/crm_helper.rb', line 103

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) %>



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

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



1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
# File 'app/helpers/crm_helper.rb', line 1306

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-body-secondary')
  when 'unchecked'
    (:span, 'Not checked', class: 'text-body-secondary')
  else
    (:span, check_value&.titleize || '', class: 'text-body-secondary')
  end
end