Class: Pdf::Document::Quote

Inherits:
BaseService
  • Object
show all
Includes:
Base
Defined in:
app/services/pdf/document/quote.rb

Defined Under Namespace

Classes: Result

Constant Summary

Constants included from Base

Base::FONT, Base::NIMBUS_SANS_PATH, Base::NIMBUS_SANS_PATH_BOLD, Base::WY_LOGO_PATH

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ Quote

Returns a new instance of Quote.



7
8
# File 'app/services/pdf/document/quote.rb', line 7

def initialize(options={})
end

Instance Method Details

#adjust_column_widths(column_widths_array, column_header_widths) ⇒ Object



742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
# File 'app/services/pdf/document/quote.rb', line 742

def adjust_column_widths(column_widths_array, column_header_widths)
 column_widths_array.each_with_index do |width, i|
   next if i == 0

   max_column_width = column_widths_array.max
   max_column_index = column_widths_array.index(max_column_width)

   next unless width < column_header_widths[i]

   difference = column_header_widths[i] - width

   column_widths_array[i] += difference

   column_widths_array[max_column_index] -= difference
 end

 column_widths_array
end

#call(quote, options = {}) ⇒ Object



10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
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/services/pdf/document/quote.rb', line 10

def call(quote, options = {})

  @quote = quote if quote
  @options = options if options
  @salutation = begin
    @quote.primary_party.name
  rescue StandardError
    nil
  end || begin
    @quote.shipping_address.person_name
  rescue StandardError
    nil
  end || @quote.customer.name
  @quote_recipient = begin
    @quote.customer.full_name
  rescue StandardError
    nil
  end
  @line_grouped = @quote.line_items_grouped_by_room_configuration(group_by_item_category: false)
  @msrp_pricing = !@quote.has_discounts?
  @currency_symbol = @quote.currency_symbol
  @is_snow_melting = @quote.line_items.goods.any?(&:is_snow_melting_product?)
  @show_discount_breakdown = @options[:show_discount_breakdown].to_b
  @colspan = @msrp_pricing == true || @show_discount_breakdown == true ? 5 : 7
  @total_saved = @quote.line_items.to_a.sum(&:discounts_total)
  @has_suggested_items = @options[:suggested_items]&.any? { |_, lines| lines.present? }
  @show_cover_letter = @options[:show_cover_letter].to_b
  @show_line_items = @options[:show_line_items].to_b
  @show_suggested_services = @options[:show_suggested_services].to_b
  @show_suggested_add_ons = @options[:show_suggested_add_ons].to_b
  @show_suggested_add_ons = @options[:show_suggested_add_ons].to_b && (@has_suggested_items || @options[:suggested_services].present?)
  @show_ordering_information = @options[:show_ordering_information].to_b
  @has_custom_items = @quote.line_items.custom_mats.present? || @quote.line_items.countertop_heaters.present?
  @show_room_summary = @options[:show_room_summary].to_b
  @condensed_text = @options[:condensed_text].to_b
  @proforma_invoice = @options[:proforma_invoice].to_b
  @applies_for_smartinstall = @show_suggested_services && @quote.applies_for_smartinstall
  @smartinstall_data = @quote.smartinstall_data if @show_suggested_services && @quote.applies_for_smartinstall
  @applies_for_smartsupport = @quote.applies_for_smartsupport
  @smartsupport_data = @quote.smartsupport_info if @show_suggested_services && @quote.applies_for_smartsupport
  @has_onsite_smartsupport = @quote.has_onsite_smartsupport? if @show_suggested_services && @quote.applies_for_smartsupport
  @show_detailed_description = true

  composer = build_composer(margin: [80, 25, 50, 25])

  show_room_summary(composer) if @show_room_summary == true

  show_line_items(composer) if @show_line_items == true

  show_smartguide_info(composer) if @applies_for_smartsupport && @smartsupport_data.present?

  composer.document.pages.each do |page|
    canvas = page.canvas
    page_width  = page.box(:media).width
    page_height = page.box(:media).height

    logo_path = Pdf::Config::LOGO_PATH
    support_path = Rails.public_path.join('images', 'pdf', '247.png').to_s

    canvas.image(logo_path, at: [17.5, page_height - 65], width: 200)
    canvas.composer.formatted_text(
      [
        { text: " #{@proforma_invoice ? 'Pro Forma Invoice' : 'Quote'} ##{@quote.reference_number}\n", style: { font: FONT, font_size: 14.5, line_height: 14, fill_color: '333' } },
        { text: "Job: #{@quote.opportunity.name}", style: { font: FONT, font_size: 11, line_height: 14, fill_color: '999' } }
      ],
      align: :right,
      padding: [20, 30],
      style: { text_align: :right },
      width: 305
    )
    canvas.composer.formatted_text([
                                     { text: @quote.company.legal_name, style: { font: FONT, font_size: 12 } },
                                     { text: ' | ', style: { font_size: 12 } },
                                     { box: [:image, support_path], height: 12, valign: :text, padding: [0, 0, -3, 0] },
                                     { text: ' Technical Support', style: { font: FONT, font_size: 12 } },
                                     { text: ' | ', style: { font_size: 12 } },
                                     { text: '(800) 875-5285', style: { font: FONT, font_size: 12 } },
                                     { text: ' | ', style: { font_size: 12 } },
                                     { link: 'www.WarmlyYours.com/support', style: { font: FONT, font_size: 12 } }
                                   ], align: :center, valign: :bottom, style: { padding: [0, 0, 3, 0] })
  end

  buffer = StringIO.new
  composer.write(buffer, optimize: true)
  buffer.string
  Result.new(pdf: buffer.string, quote: @quote)
end

#show_custom_items_information(composer) ⇒ Object



515
516
517
518
519
520
521
522
523
524
# File 'app/services/pdf/document/quote.rb', line 515

def show_custom_items_information(composer)
  l = composer.document.layout

  composer.text('Returns Information', style: { font: "#{FONT} bold", font_size: 14, fill_color: '444', text_align: :left })

  composer.text('Customized products, like TempZone Custom Mats and FeelsWarm Countertop Heaters, are ineligible for returns, except in the case of a manufacturing defect.',
               style: { font: FONT, font_size: 12, fill_color: '444', text_align: :left, line_height: 15 })

  composer.text("\n")
end

#show_line_items(composer) ⇒ Object



275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
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
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
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
# File 'app/services/pdf/document/quote.rb', line 275

def show_line_items(composer)
  l = composer.document.layout

  show_quote_info(composer) unless @show_room_summary == true

  grand_total = BigDecimal('0.00')

  @line_grouped.each do |rc, line_items|
    next if line_items.empty?

    group_subtotal = line_items.sum(&:total)
    grand_total += group_subtotal
    long_name = rc.is_a?(String) ? rc : "#{rc.name_with_config}"
    plan = rc.is_a?(String) ? nil : "Plan ##{rc.reference_number}"
    short_name = rc.is_a?(String) ? rc : rc.name

    # Header for each product category
    composer.text(long_name, style: { font: FONT, font_size: 14, fill_color: '444', padding: [0, 0, 2, 0] })
    composer.text(plan, style: { font: FONT, font_size: 11, fill_color: '777', padding: [0, 0, 7, 0] }) if plan

    table_headers = if @msrp_pricing == true || @show_discount_breakdown == true
                      [l.text('Part #', style: { font: "#{FONT} bold", font_size: 12, fill_color: '333', text_align: :center }), l.text('Description', style: { font: "#{FONT} bold", font_size: 12, fill_color: '333', text_align: :center }), l.text('Qty', style: { font: "#{FONT} bold", font_size: 12, fill_color: '333', text_align: :center }), l.text('Price Ea.', style: { font: "#{FONT} bold", font_size: 12, fill_color: '333', text_align: :center }),
                       l.text('Total', style: { font: "#{FONT} bold", font_size: 12, fill_color: '333', text_align: :center })]
                    else
                      [l.text('Part #', style: { font: "#{FONT} bold", font_size: 12, fill_color: '333', text_align: :center }), l.text('Description', style: { font: "#{FONT} bold", font_size: 12, fill_color: '333', text_align: :center }), l.text('Qty', style: { font: "#{FONT} bold", font_size: 12, fill_color: '333', text_align: :center }), l.text('MSRP Ea.', style: { font: "#{FONT} bold", font_size: 12, fill_color: '333', text_align: :center }),
                       l.text('MSRP Total', style: { font: "#{FONT} bold", font_size: 12, fill_color: '333', text_align: :center }), l.text('Discount', style: { font: "#{FONT} bold", font_size: 12, fill_color: '333', text_align: :center }), l.text('Cost', style: { font: "#{FONT} bold", font_size: 12, fill_color: '333', text_align: :center })]
                    end

    subtotal_colspan = @msrp_pricing == true || @show_discount_breakdown == true ? 5 : 7

    # Create a table container
    table_data = []

    # Add table headers as the first row
    table_data << table_headers

    dynamic_widths = {
      width_qty: 0,
      width_retail_price_per_unit: 0,
      width_retail_line_total: 0,
      width_discount: 0,
      width_discounted_total: 0
    }

    LineItem.group_by_category(line_items).each do |product_category_name, category_line_items|
      widths = show_quote_table_line(composer, table_data, product_category_name, category_line_items)

      # Update the dynamic widths by comparing existing values with the new ones and keeping the maximum width
      dynamic_widths[:width_qty] = [dynamic_widths[:width_qty], widths[:width_qty]].max
      dynamic_widths[:width_retail_price_per_unit] = [dynamic_widths[:width_retail_price_per_unit], widths[:width_retail_price_per_unit]].max
      dynamic_widths[:width_retail_line_total] = [dynamic_widths[:width_retail_line_total], widths[:width_retail_line_total]].max
      dynamic_widths[:width_discount] = [dynamic_widths[:width_discount], widths[:width_discount]].max
      dynamic_widths[:width_discounted_total] = [dynamic_widths[:width_discounted_total], widths[:width_discounted_total]].max
    end

    # Calculate the total width of all dynamic ccolumns, including discount columns
    total_dynamic_width = dynamic_widths.values.sum

    scale_factor = ((461 / total_dynamic_width.to_f) * (1.085 - 0.915)) + 0.915

    # Apply the scale factor to the dynamic widths
    dynamic_widths = dynamic_widths.transform_values do |width|
      (width * scale_factor).round
    end

    # Define column widths based on the received dynamic widths
    column_widths = if @msrp_pricing == true || @show_discount_breakdown == true
                      [99, 560 - dynamic_widths[:width_retail_price_per_unit] - dynamic_widths[:width_retail_line_total] - dynamic_widths[:width_qty] - 100, dynamic_widths[:width_qty], dynamic_widths[:width_retail_price_per_unit],
                       dynamic_widths[:width_retail_line_total]]
                    else
                      [99, 560 - dynamic_widths.values.sum - 100, dynamic_widths[:width_qty], dynamic_widths[:width_retail_price_per_unit], dynamic_widths[:width_retail_line_total], dynamic_widths[:width_discount],
                       dynamic_widths[:width_discounted_total]]
                    end
    subtotal = [
      [
        { content: l.text('Subtotal for ' + "#{short_name} " + ActionController::Base.helpers.number_to_currency(group_subtotal, unit: @currency_symbol), style: { font: "#{FONT} bold", font_size: 12, text_align: :right, fill_color: '444', padding: [0, 0, 20, 0] }),
          col_span: subtotal_colspan }
      ]
    ]

    table_data << subtotal.flatten(1)

    block = lambda do |total_rows|
      lambda do |cell|
        cell.style.text_valign = :center
        cell.style.border = {
          color: 'CDCDCD',
          width: if cell.row == 0
                   [0.5, 0, 0.5, 0]
                 else
                   cell.row == total_rows - 1 ? 0 : [0, 0, 0.5, 0]
                 end
        }
      end
    end

    # Render the table using HexaPDF's table method
    composer.table(table_data, column_widths: column_widths, cell_style: block.call(table_data.size), style: { font: FONT, font_size: 10 })
  end
  show_quote_summary(composer, grand_total)
  composer.text("\n\n")
  show_quote_discounts(composer)
  if @is_snow_melting && @quote.line_items.any?{|li| li.sku == 'SCP-120' || li.sku == 'SCE-120'} # Any quotes with these items should have the following copy, per basecamp todo: https://3.basecamp.com/3233448/buckets/12312384/todos/9051136153
    composer.text("\n\n")
    composer.text(
      "To comply with the energy conservation requirements of your state, this control unit includes a slab temperature and an ambient/precipitation sensor. Please consult a local inspector or authority having jurisdiction (AHJ) to explore all available options from WarmlyYours, some of which may be lower-cost alternatives that do not include a slab temperature sensor.", style: {
        font: FONT, font_size: 12, fill_color: '444', text_align: :left, line_height: 15
      }
    )
  end
  if @is_snow_melting
    composer.text("\n\n")
    composer.text(
      "Based on your project's specifics, we have designed a system with the most suitable coverage, products, and controller.\n\nIf you are exploring other options, your account manager can assist with alternatives such as reducing the coverage area (for e.g., tire track coverage for driveways) or selecting a different controller, coverage, or zoning configuration.", style: {
        font: FONT, font_size: 12, fill_color: '444', text_align: :left, line_height: 15
      }
    )
  end
  composer.text("\n\n")
  show_ordering_information(composer)
  composer.text("\n\n")
  show_custom_items_information(composer) if @has_custom_items

end

#show_ordering_information(composer) ⇒ Object



488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
# File 'app/services/pdf/document/quote.rb', line 488

def show_ordering_information(composer)
  l = composer.document.layout
  # After the discount section, check if ordering information should be displayed
  return unless @show_ordering_information == true and @quote.main_rep.present?

  # Add heading for "How to Order"
  composer.text('How to Order', style: { font: FONT, font_size: 14, fill_color: '444', padding: [0, 0, 10, 0] })

  composer.formatted_text([
                            { text: 'Please contact your account manager to order, or ', style: { font: FONT, font_size: 11, fill_color: '444' } },
                            { link: "https://www.warmlyyours.com/quote-lookup?quote_id=#{@quote.id}", text: ' click here to purchase the quote, online.', style: { font: FONT, font_size: 11, fill_color: 'hp-blue-dark', underline: false } }
                          ], style: { padding: [0, 0, 10, 0] })

  # Account Manager Section (two columns)
  table_data = [
    [l.text('Account Manager:', style: { font: "#{FONT} bold", font_size: 12, fill_color: '444' }), l.text(@quote.main_rep.name, style: { font: FONT, font_size: 12, fill_color: '444' })],
    [l.text('Phone #:', style: { font: "#{FONT} bold", font_size: 12, fill_color: '444' }), l.text(@quote.main_rep.company_phone, style: { font: FONT, font_size: 12, fill_color: '444' })],
    [l.text('Email:', style: { font: "#{FONT} bold", font_size: 12, fill_color: '444' }), l.text(@quote.main_rep.email, style: { font: FONT, font_size: 12, fill_color: '444' })]
  ]

  # Define column widths for the two columns
  column_widths = [120, 300]

  # Render the account manager information in a table
  composer.table(table_data, column_widths: column_widths, cell_style: { border: { width: [0, 0, 0, 0], color: 'CDCDCD' }, padding: [0, 0, 3, 0] }, style: { font: FONT, font_size: 12, fill_color: '444' })
end

#show_quote_discounts(composer) ⇒ Object



464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
# File 'app/services/pdf/document/quote.rb', line 464

def show_quote_discounts(composer)
  l = composer.document.layout

  return unless !(@msrp_pricing && @quote.customer.pricing_program_discount > 0) && @total_saved > 0

  # Create a header for the discounts
  composer.text("Your discounts saved you #{ActionController::Base.helpers.number_to_currency(@total_saved, unit: @currency_symbol)}#{' on this quote.' unless @proforma_invoice == true}",
                style: { font: FONT, font_size: 14, fill_color: '444', padding: [0, 0, 5, 0] })

  # Create the table for coupons
  table_data = []

  # Loop through non-blacklisted discounts
  @quote.discounts.non_blacklisted.each do |discount|
    table_data << [
      { content: l.text(discount.coupon.code, style: { font: FONT, font_size: 11, fill_color: '444', text_align: :left }), col_span: @quote.discounts.non_blacklisted.size > 1 ? 3 : 1 },
      { content: l.text(discount.coupon.title, style: { font: FONT, font_size: 11, fill_color: '444', text_align: :left }), col_span: @quote.discounts.non_blacklisted.size > 1 ? 11 : 1 }
    ]
  end

  # Render the table with HexaPDF's table method
  composer.table(table_data, cell_style: { border: { width: [0.5, 0, 0, 0], color: 'CDCDCD' } }, style: { font: FONT, font_size: 12, fill_color: '444' })
end

#show_quote_info(composer) ⇒ Object



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

def show_quote_info(composer)
  unless @quote_recipient.nil?
    composer.container do |container|
      # Hidden header placeholder (for accessibility; actual header content is rendered below in the footer)
      container.text('', style: { font: "#{FONT} bold", font_size: 1 })
      container.text('Exclusively prepared for:', style: { fill_color: '888', font: FONT, font_size: 12 }, padding: [0, 0, 2, 0])
      container.text(@quote_recipient, style: { font: FONT, font_size: 15, fill_color: '222' }, padding: [0, 0, 7.5, 0])
    end
  end

  composer.container(width: 562, style: { position: :flow, padding: [0, 0, 15, 0] }) do |container|
    # Left Column
    container.container(width: 281, style: { position: :float, align: :left }) do |left_container|
      if @quote.contact
        left_container.container(width: 281, style: { position: :flow, padding: [0, 0, 3, 0] }) do |row|
          row.container(width: 115.5, style: { position: :float, align: :left }) do |col1|
            col1.text('Contact:', style: { font: "#{FONT} bold", font_size: 12, text_align: :left, fill_color: '444' })
          end
          row.container(width: 165.5, style: { position: :float, align: :right }) do |col2|
            col2.text(@quote.contact.full_name, style: { font: FONT, font_size: 12, text_align: :left, fill_color: '555', padding: [0, 10, 0, 0] })
          end
        end
      end
      left_container.container(width: 281, style: { position: :flow, padding: [0, 0, 3, 0] }) do |row|
        row.container(width: 115.5, style: { position: :float, align: :left }) do |col1|
          col1.text('Date:', style: { font: "#{FONT} bold", font_size: 12, text_align: :left, fill_color: '444' })
        end
        row.container(width: 165.5, style: { position: :float, align: :right }) do |col2|
          col2.text(Date.current.to_fs(:long), style: { font: FONT, font_size: 12, text_align: :left, fill_color: '555', padding: [0, 10, 0, 0] })
        end
      end
      left_container.container(width: 281, style: { position: :flow, padding: [0, 0, 3, 0] }) do |row|
        row.container(width: 115.5, style: { position: :float, align: :left }) do |col1|
          col1.text('Valid Until:', style: { font: "#{FONT} bold", font_size: 12, text_align: :left, fill_color: '444' })
        end
        row.container(width: 165.5, style: { position: :float, align: :right }) do |col2|
          col2.text((@quote.expiration_date&.to_fs(:long).presence || ''), style: { font: FONT, font_size: 12, text_align: :left, fill_color: '555', padding: [0, 10, 0, 0] })
        end
      end
      if @quote.opportunity.summary.present?
        left_container.container(width: 281, style: { position: :flow, padding: [0, 0, 3, 0] }) do |row|
          row.container(width: 115.5, style: { position: :float, align: :left }) do |col1|
            col1.text('Job Summary:', style: { font: "#{FONT} bold", font_size: 12, text_align: :left, fill_color: '444' })
          end
          row.container(width: 165.5, style: { position: :float, align: :right }) do |col2|
            col2.text(@quote.opportunity.summary, style: { font: FONT, font_size: 12, text_align: :left, fill_color: '555', padding: [0, 10, 0, 0] })
          end
        end
      end
      if @quote.suffix.present?
        left_container.container(width: 281, style: { position: :flow, padding: [0, 0, 3, 0] }) do |row|
          row.container(width: 115.5, style: { position: :float, align: :left }) do |col1|
            col1.text('Quote Info:', style: { font: "#{FONT} bold", font_size: 12, text_align: :left, fill_color: '444' })
          end
          row.container(width: 165.5, style: { position: :float, align: :right }) do |col2|
            col2.text(@quote.suffix, style: { font: FONT, font_size: 12, text_align: :left, fill_color: '555', padding: [0, 10, 0, 0] })
          end
        end
      end
      left_container.container(width: 281, style: { position: :flow, padding: [0, 0, 3, 0] }) do |row|
        row.container(width: 115.5, style: { position: :float, align: :left }) do |col1|
          col1.text('Currency', style: { font: "#{FONT} bold", font_size: 12, text_align: :left, fill_color: '444' })
        end
        row.container(width: 165.5, style: { position: :float, align: :right }) do |col2|
          col2.text(@quote.currency, style: { font: FONT, font_size: 12, text_align: :left, fill_color: '555', padding: [0, 10, 0, 0] })
        end
      end
      left_container.container(width: 281, style: { position: :flow, padding: [0, 0, 3, 0] }) do |row|
        row.container(width: 115.5, style: { position: :float, align: :left }) do |col1|
          col1.text('Terms:', style: { font: "#{FONT} bold", font_size: 12, text_align: :left, fill_color: '444' })
        end
        row.container(width: 165.5, style: { position: :float, align: :right }) do |col2|
          col2.text("#{@quote.customer.terms}#{'*' if @quote.customer.terms == 'Due' && !@quote.customer.is_homeowner?}", style: { font: FONT, font_size: 12, text_align: :left, fill_color: '555', padding: [0, 10, 0, 0] })
        end
      end
      if @quote.customer.terms == 'Due' && !@quote.customer.is_homeowner?
        left_container.container(width: 281, style: { position: :float, align: :left }) do |row|
          row.text('* Terms available with credit application', style: { font: FONT, font_size: 12, text_align: :left, fill_color: '555' })
        end
      end
    end
    container.container(width: 281, style: { position: :float, align: :right }) do |right_container|
      right_container.formatted_text([
                                       { text: @quote.is_warehouse_pickup? ? "Pickup from:\n" : "Ship to:\n", style: { font: "#{FONT} bold", font_size: 12, fill_color: '444' } },
                                       { text: @quote.shipping_address.nil? ? 'No shipping address provided.' : @quote.shipping_address.to_array(with_country: false).join("\n"),
                                         style: { font: FONT, font_size: 12, fill_color: '555', line_height: 16 } }
                                     ])
    end
  end
end

#show_quote_summary(composer, grand_total) ⇒ Object



400
401
402
403
404
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
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# File 'app/services/pdf/document/quote.rb', line 400

def show_quote_summary(composer, grand_total)
  l = composer.document.layout
  # Initialize total_discounts based on condition
  total_discounts = if @msrp_pricing && @quote.customer.pricing_program_discount > 0
                      0
                    else
                      @quote.discount || 0
                    end

  # Product subtotal
  composer.text('Summary', style: { font: FONT, font_size: 15, fill_color: '444', padding: [0, 0, 5, 0] }) if @proforma_invoice == true
  composer.text('Quote Summary', style: { font: FONT, font_size: 15, fill_color: '444', padding: [0, 0, 5, 0] }) unless @proforma_invoice == true

  table_data = []

  # Subtotal row
  table_data << [l.text('Subtotal for all products', style: { font: FONT, font_size: 12, fill_color: '444', text_align: :left }),
                 l.text(ActionController::Base.helpers.number_to_currency(grand_total, unit: @currency_symbol), style: { font: FONT, font_size: 12, fill_color: '444', text_align: :right })]

  # Shipping row
  if @quote.shipping_address.nil?
    table_data << [l.text('Shipping', style: { font: FONT, font_size: 12, fill_color: '444', text_align: :left }), l.text('To be determined', style: { font: FONT, font_size: 12, fill_color: '444', text_align: :right })]
  else
    @quote.line_items.shipping_only.each do |li|
      # For full price, discount, and line total
      if (@msrp_pricing && @quote.customer.pricing_program_discount.positive?) || li.price_total == li.discounted_total || @show_discount_breakdown
        full_price = nil
        discount = nil
        line_total = li.price_total
      else
        full_price = li.price_total
        discount = li.discounted_total - li.price_total
        line_total = li.discounted_total
      end
      grand_total += line_total

      table_data << [l.text("#{li.shipping_name}:", style: { font: FONT, font_size: 12, fill_color: '444', text_align: :left }),
                     l.text(ActionController::Base.helpers.number_to_currency(line_total, unit: @currency_symbol), style: { font: FONT, font_size: 12, fill_color: '444', text_align: :right })]

      # If discount breakdown is enabled, list each discount
      next unless @show_discount_breakdown

      li.line_discounts.each do |ld|
        table_data << [l.text(ld.coupon.title, style: { font: FONT, font_size: 12, fill_color: '444', text_align: :left }),
                       l.text(ActionController::Base.helpers.number_to_currency(ld.amount, unit: @currency_symbol), style: { font: FONT, font_size: 12, fill_color: '444', text_align: :right })]
      end
    end
  end

  # Taxes row
  @quote.taxes_grouped_by_type.each do |tax_type, tax_info|
    table_data << [l.text(tax_info[:name], style: { font: FONT, font_size: 12, fill_color: '444', text_align: :left }),
                   l.text(ActionController::Base.helpers.number_to_currency(tax_info[:tax_amount], unit: @currency_symbol), style: { font: FONT, font_size: 12, fill_color: '444', text_align: :right })]
    grand_total += tax_info[:tax_amount]
  end

  # Add grand total row
  table_data << [l.text('Grand Total', style: { font: "#{FONT} bold", font_size: 12, fill_color: '444', text_align: :left }),
                 l.text(ActionController::Base.helpers.number_to_currency(grand_total, unit: @currency_symbol), style: { font: "#{FONT} bold", font_size: 12, fill_color: '444', text_align: :right })]

  # Render the table with HexaPDF's table method
  composer.table(table_data, cell_style: { border: { width: [0.5, 0, 0, 0], color: 'CDCDCD' } }, style: { font: FONT, font_size: 12, fill_color: '444' })
end

#show_quote_table_line(composer, table_data, product_category_name, category_line_items) ⇒ Object



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'app/services/pdf/document/quote.rb', line 189

def show_quote_table_line(composer, table_data, product_category_name, category_line_items)
  l = composer.document.layout
  column_widths ||= {
    width_qty: 0,
    width_retail_price_per_unit: 0,
    width_retail_line_total: 0,
    width_discount: 0,
    width_discounted_total: 0
  }

  # Add category header for the product category
  category_header = [
    [
      {
        content: l.text(product_category_name.pluralize, style: { font: "#{FONT} bold", font_size: 12, fill_color: '333' }),
        col_span: @msrp_pricing == true || @show_discount_breakdown == true ? 5 : 7
      }
    ]
  ]
  table_data << category_header.flatten(1)

  # Process each line item and calculate dynamic column widths
  category_line_items.each do |line_item|
    show_detailed_description = @show_detailed_description ? false : @show_detailed_description
    qty = line_item.quantity
    retail_price_per_unit = line_item.price
    retail_line_total = line_item.price_total
    discount = if line_item.price_total == line_item.discounted_total
                 nil
               else
                 line_item.discounted_total - line_item.price_total
               end

    width_qty = text_width(composer, qty.to_s, 12)
    width_retail_price_per_unit = text_width(composer, ActionController::Base.helpers.number_to_currency(retail_price_per_unit, unit: @currency_symbol), 12)
    width_retail_line_total = text_width(composer, ActionController::Base.helpers.number_to_currency(retail_line_total, unit: @currency_symbol), 12)
    width_discount = text_width(composer, ActionController::Base.helpers.number_to_currency(discount, unit: @currency_symbol), 12) unless discount.nil?
    width_discounted_total = text_width(composer, ActionController::Base.helpers.number_to_currency(line_item.discounted_total, unit: @currency_symbol), 12)

    # Store the calculated widths in the column_widths hash
    column_widths[:width_qty] = [[column_widths[:width_qty], text_width(composer, 'Qty', 12) + 5].max, width_qty.round].max
    column_widths[:width_retail_price_per_unit] = [column_widths[:width_retail_price_per_unit], width_retail_price_per_unit.round].max
    column_widths[:width_retail_line_total] = [column_widths[:width_retail_line_total], width_retail_line_total.round].max
    column_widths[:width_discount] = [column_widths[:width_discount], discount.nil? || width_discount < text_width(composer, 'Discount', 12) ? text_width(composer, 'Discount', 12) : width_discount.round].max
    column_widths[:width_discounted_total] = [column_widths[:width_discounted_total], width_discounted_total.round].max

    # Prepare the row data dynamically based on whether we show MSRP or not
    row_data = if @msrp_pricing == true || @show_discount_breakdown == true
                 [
                   l.text(line_item.sku, style: { font: FONT, font_size: 12, fill_color: '333' }),
                   l.text(line_item.description || line_item.name, style: { font: FONT, font_size: 11, fill_color: '333', line_height: 15 }),
                   l.text(qty.to_s, style: { font: FONT, text_align: :center, font_size: 12, fill_color: '333' }),
                   l.text(ActionController::Base.helpers.number_to_currency(retail_price_per_unit, unit: @currency_symbol), style: { font: FONT, text_align: :center, font_size: 12, fill_color: '333' }),
                   l.text(ActionController::Base.helpers.number_to_currency(retail_line_total, unit: @currency_symbol), style: { font: FONT, text_align: :center, font_size: 12, fill_color: '333' })
                 ]
               else
                 [
                   l.text(line_item.sku, style: { font: FONT, font_size: 12, fill_color: '444', line_height: 15 }),
                   l.text(line_item.description || line_item.name, style: { font: FONT, font_size: 11, fill_color: '444', line_height: 15 }),
                   l.text(qty.to_s, style: { font: FONT, text_align: :center, font_size: 12, fill_color: '444' }),
                   l.text(ActionController::Base.helpers.number_to_currency(retail_price_per_unit, unit: @currency_symbol), style: { font: FONT, text_align: :center, font_size: 12, fill_color: '444' }),
                   l.text(ActionController::Base.helpers.number_to_currency(retail_line_total, unit: @currency_symbol), style: { font: FONT, text_align: :center, font_size: 12, fill_color: '444', strikeout: !discount.nil? }),
                   l.text(discount.nil? ? '' : ActionController::Base.helpers.number_to_currency(discount, unit: @currency_symbol), style: { font: FONT, text_align: :center, font_size: 12, fill_color: '444' }),
                   l.text(ActionController::Base.helpers.number_to_currency(line_item.discounted_total, unit: @currency_symbol), style: { font: FONT, text_align: :center, font_size: 12, fill_color: '444' })
                 ]
               end

    table_data << row_data

    # Handle discount breakdown if applicable
    if @show_discount_breakdown
      line_item.line_discounts.each do |ld|
        table_data << ['', l.text(ld.coupon.title, style: { font: FONT, font_size: 12, fill_color: '666', line_height: 15 }), '', '', l.text(ActionController::Base.helpers.number_to_currency(ld.amount, unit: @currency_symbol), style: { font: FONT, font_size: 12, fill_color: '666', text_align: :center })] # Add discount row
      end
    end

    # Show detailed description if the flag is set
    if show_detailed_description
      table_data << [line_item.item.detailed_description_html, '', '', '', '', '', ''] # Add detailed description row with colspan
    end
  end

  # Return the calculated column widths
  column_widths
end

#show_room_summary(composer) ⇒ Object



526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
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
# File 'app/services/pdf/document/quote.rb', line 526

def show_room_summary(composer)
  l = composer.document.layout

  show_quote_info(composer)

  # Initialize grand_total and num variables
  grand_total = BigDecimal('0.00')
  num = 0

  # Create table data array
  table_data = []

  # Add table headers
  table_data << [
    l.text('', style: { font: "#{FONT} bold", font_size: 12, fill_color: '444', text_align: :left }),
    l.text('Plan #', style: { font: "#{FONT} bold", font_size: 12, fill_color: '444', text_align: :left }),
    l.text('Heated Space Name', style: { font: "#{FONT} bold", font_size: 12, fill_color: '444' }),
    l.text('Surface Type', style: { font: "#{FONT} bold", font_size: 12, fill_color: '444' }),
    l.text('Heating System', style: { font: "#{FONT} bold", font_size: 12, fill_color: '444' }),
    l.text('Control', style: { font: "#{FONT} bold", font_size: 12, fill_color: '444' }),
    l.text('Subtotal', style: { font: "#{FONT} bold", font_size: 12, fill_color: '444' })
  ]

  column_widths ||= {
     width_num: 0,
     width_plan: 0,
     width_long_name: 0,
     width_floor_type: 0,
     width_heating_system: 0,
     width_control: 0,
     width_group_subtotal: 0
   }

  # Iterate through the line groups
  @line_grouped.each do |rc, line_items|
    next if line_items.empty?

    # Increment the row number
    num += 1

    # Calculate the group subtotal and update the grand total
    group_subtotal = line_items.sum(&:total)
    grand_total += group_subtotal

    # Extract the necessary data for the row
    long_name = rc.is_a?(String) ? rc : "#{rc.name}"
    plan = rc.is_a?(String) ? nil : rc.reference_number
    short_name = rc.is_a?(String) ? rc : rc.name
    floor_type = rc.is_a?(String) ? '' : rc.floor_type.try(:name)
    heating_system = rc.is_a?(String) ? '' : rc.heating_system_type_name
    control = rc.is_a?(String) ? '' : LineItem.where(id: line_items.collect(&:id)).electrical_plan_controls.first.try(:sku)
    subtotal = ActionController::Base.helpers.number_to_currency(group_subtotal, unit: @currency_symbol)

    width_num = text_width(composer, num.to_s, 12) + 10
    width_plan = text_width(composer, plan.to_s, 12)
    width_long_name = text_width(composer, long_name, 12)
    width_floor_type = text_width(composer, floor_type, 12)
    width_heating_system = text_width(composer, heating_system, 12)
    width_control = text_width(composer, control, 12) || text_width(composer, 'Control', 12).round
    width_group_subtotal = text_width(composer, subtotal, 12)

    column_widths[:width_num] = [column_widths[:width_num], width_num.round].max
    column_widths[:width_plan] = [column_widths[:width_plan], width_plan.round, text_width(composer, 'Plan #', 12).round].max
    column_widths[:width_long_name] = [column_widths[:width_long_name], width_long_name.round, text_width(composer, 'Heated Space Name', 12).round].max
    column_widths[:width_floor_type] = [column_widths[:width_floor_type], width_floor_type.round, text_width(composer, 'Surface Type', 12).round].max
    column_widths[:width_heating_system] = [column_widths[:width_heating_system], width_heating_system.round, text_width(composer, 'Heating System', 12).round].max
    column_widths[:width_control] = [column_widths[:width_control], width_control.round, text_width(composer, 'Control', 12).round].max
    column_widths[:width_group_subtotal] = [column_widths[:width_group_subtotal], width_group_subtotal.round, text_width(composer, 'Subtotal', 12).round].max

    # Add the row data to the table
    table_data << [
      l.text("#{num}. ", style: { font: FONT, font_size: 12, fill_color: '444' }),
      l.text(plan.to_s, style: { font: FONT, font_size: 12, fill_color: '444' }),
      l.text(long_name, style: { font: FONT, font_size: 12, fill_color: '444' }),
      l.text(floor_type, style: { font: FONT, font_size: 12, fill_color: '444' }),
      l.text(heating_system, style: { font: FONT, font_size: 12, fill_color: '444' }),
      l.text((control.presence || ''), style: { font: FONT, font_size: 12, fill_color: '444' }),
      l.text(ActionController::Base.helpers.number_to_currency(group_subtotal, unit: @currency_symbol), style: { font: FONT, font_size: 12, fill_color: '444' })
    ]
  end

  max_page_width = 560

  total_dynamic_width = column_widths.values.sum

  if total_dynamic_width > max_page_width
    scale_factor = max_page_width.to_f / total_dynamic_width.to_f

    column_widths = column_widths.transform_values { |width| (width * scale_factor).round }

  elsif total_dynamic_width < max_page_width
    total_remaining_space = max_page_width - total_dynamic_width
    spacing = (total_remaining_space / column_widths.size).round

    column_widths = column_widths.transform_values { |width| width + spacing }
  else
    column_widths = column_widths
  end

  column_header_widths = [
    text_width(composer, '', 12).round,
    text_width(composer, 'Plan #', 12).round,
    text_width(composer, 'Heated Space Name', 12).round + 10,
    text_width(composer, 'Surface Type', 12).round,
    text_width(composer, 'Heating System', 12).round,
    text_width(composer, 'Control', 12).round + 10,
    text_width(composer, 'Subtotal', 12).round + 10
  ]

  column_widths_array = [
    column_widths[:width_num],
    column_widths[:width_plan],
    column_widths[:width_long_name],
    column_widths[:width_floor_type],
    column_widths[:width_heating_system],
    column_widths[:width_control],
    column_widths[:width_group_subtotal]
  ]

  adjusted_column_widths = adjust_column_widths(column_widths_array, column_header_widths)

  composer.table(table_data, column_widths: adjusted_column_widths, cell_style: { border: { width: [0.5, 0, 0, 0], color: 'CDCDCD' }, padding: [5, 1] }, style: { font: FONT, font_size: 12, fill_color: '444' })
  composer.text("\n\n")
  show_quote_summary(composer, grand_total)
  composer.text("\n\n")
  show_quote_discounts(composer)
  composer.text("\n\n")
end

#show_smartguide_info(composer) ⇒ Object



655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
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
# File 'app/services/pdf/document/quote.rb', line 655

def show_smartguide_info(composer)
  l = composer.document.layout
  composer.text("\n\n")

  composer.text('SmartGuide: Optional Installation Supervision Support for Your Project',
                style: { font: FONT, font_size: 14, fill_color: '444', padding: [0, 0, 5, 0] })

  # Paragraph Text
  composer.text("We know that not everyone is a radiant heating expert (that's our job!). That's why we've condensed over 25 years of experience into our SmartGuide services. These optional services allow for WarmlyYours Radiant Experts to provide installation support for the actual installers. You can choose to have our staff supervise onsite or remotely with video conferencing. This service is a great way to ensure a successful installation project.",
                style: { font: FONT, font_size: 12, fill_color: '555', line_height: 15, padding: [0, 0, 10, 0] })

  # Create a table for services
  table_data = []

  @smartsupport_data.compact.each_with_index do |(service, service_data), index|
    # Check if the current service is the last one in the array
    is_last_service = index == @smartsupport_data.compact.size - 1

    if service_data.is_a?(Array)
      table_data << [
        { content: l.text(service, style: { font: FONT, font_size: 12, fill_color: '444', text_align: :left }), col_span: 11 },
        { content: l.text("#{service_data[0]} miles", style: { font: FONT, font_size: 12, fill_color: '444', text_align: :right }), col_span: 3 },
        { content: l.text(ActionController::Base.helpers.number_to_currency(format('%.2f', service_data[1].to_f), unit: @currency_symbol), style: { font: FONT, font_size: 12, fill_color: '444', text_align: :right }), col_span: 3 }
      ]
    elsif service.include?('Service Fee')
      table_data << [
        { content: l.text(service, style: { font: FONT, font_size: 12, fill_color: '444', text_align: :left }), col_span: 14 },
        { content: l.text(ActionController::Base.helpers.number_to_currency(format('%.2f', service_data.to_f), unit: @currency_symbol), style: { font: FONT, font_size: 12, fill_color: '444', text_align: :right }), col_span: 3 }
      ]
    elsif service.include?('Total')
      # Add the row for the total service
      table_data << [
        { content: l.text("#{service}", style: { font: "#{FONT} bold", font_size: 12, fill_color: '444', text_align: :left }), col_span: 14 },
        { content: l.text(ActionController::Base.helpers.number_to_currency(format('%.2f', service_data.to_f), unit: @currency_symbol), style: { font: FONT, font_size: 12, fill_color: '444', text_align: :right }), col_span: 3 }
      ]
      # Add an extra empty row only if it is not the last service in the list
      table_data << [{ content: l.text('', padding: [2.5, 0, 2.5, 0]), col_span: 17 }] unless is_last_service
    end
  end

  block = lambda do |total_rows|
    lambda do |cell|
      cell.style.border = {
        color: 'CDCDCD',
        width: if cell.row == total_rows - 1
                 [0.5, 0, 0.5, 0]
               else
                 [0.5, 0, 0, 0]
               end
      }
    end
  end

  # Render the table with HexaPDF's table method
  composer.table(table_data, cell_style: block.call(table_data.size))

  # Further text content based on SmartSupport type
  if @has_onsite_smartsupport
    composer.text('The SmartGuide Onsite service fee includes 1 day (up to 6 hours) of onsite support. If any additional days onsite are required, there will be an additional fee of $150/day (up to 6 hours per day). There will be a $499 surcharge fee for any SmartGuide Onsite project that requires an overnight stay. The SmartGuide Onsite service fee also includes 100 miles of travel for WarmlyYours staff from the Chicago-area to the project. After the initial 100 miles, there will be a $15 fee for each additional mile.',
                  style: { font: FONT, font_size: 12, fill_color: '555', line_height: 15, padding: [10, 0, 10, 0] })
  else
    composer.text('The SmartGuide Remote service fee includes 1 hour of remote support via video conferencing. After the initial hour, there will be an additional fee of $75 per hour.',
                  style: { font: FONT, font_size: 12, fill_color: '555', line_height: 15, padding: [10, 0, 10, 0] })
  end
end

#text_width(composer, text, font_size = 12) ⇒ Object



721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
# File 'app/services/pdf/document/quote.rb', line 721

def text_width(composer, text, font_size = 12)
  return 0 if text.nil?

  font_size = font_size.to_f

  layout = HexaPDF::Layout::TextLayouter.new
  layout.style.font = FONT
  layout.style.font_size = font_size

  text_fragments = composer.document.layout.text_fragments(text, style: { font: FONT, font_size: font_size })

  text_box = HexaPDF::Layout::TextBox.new(items: text_fragments)

  frame_width = 1000
  frame = HexaPDF::Layout::Frame.new(0, 1000, frame_width, 5000)

  result = frame.fit(text_box)

  result.box.width
end