nti_receipt_builder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +272 -0
  6. data/Rakefile +12 -0
  7. data/app/assets/javascripts/nti_receipt_builder/index.js +16 -0
  8. data/app/assets/javascripts/nti_receipt_builder/receipt_builder_controller.js +297 -0
  9. data/app/assets/javascripts/nti_receipt_builder/receipt_canvas_controller.js +279 -0
  10. data/app/assets/javascripts/nti_receipt_builder/receipt_draggable_controller.js +154 -0
  11. data/app/assets/javascripts/nti_receipt_builder/receipt_print_controller.js +24 -0
  12. data/app/assets/javascripts/nti_receipt_builder/receipt_properties_controller.js +297 -0
  13. data/app/assets/stylesheets/nti_receipt_builder.css +238 -0
  14. data/app/controllers/concerns/nti_receipt_builder/templates_controller.rb +170 -0
  15. data/app/helpers/nti_receipt_builder/render_helper.rb +21 -0
  16. data/app/models/nti_receipt_builder/template.rb +79 -0
  17. data/app/views/nti_receipt_builder/render/_order_lines_element.html.erb +36 -0
  18. data/app/views/nti_receipt_builder/render/_overflow_banner.html.erb +5 -0
  19. data/app/views/nti_receipt_builder/render/_paper.html.erb +12 -0
  20. data/app/views/nti_receipt_builder/render/_text_element.html.erb +1 -0
  21. data/app/views/nti_receipt_builder/render/_variable_element.html.erb +1 -0
  22. data/app/views/nti_receipt_builder/templates/_canvas.html.erb +33 -0
  23. data/app/views/nti_receipt_builder/templates/_elements_panel.html.erb +49 -0
  24. data/app/views/nti_receipt_builder/templates/_errors.html.erb +21 -0
  25. data/app/views/nti_receipt_builder/templates/_lock_version_field.html.erb +2 -0
  26. data/app/views/nti_receipt_builder/templates/_print_assets.html.erb +15 -0
  27. data/app/views/nti_receipt_builder/templates/_properties_panel.html.erb +8 -0
  28. data/app/views/nti_receipt_builder/templates/_settings_panel.html.erb +130 -0
  29. data/app/views/nti_receipt_builder/templates/create.turbo_stream.erb +3 -0
  30. data/app/views/nti_receipt_builder/templates/edit.html.erb +54 -0
  31. data/app/views/nti_receipt_builder/templates/new.html.erb +115 -0
  32. data/app/views/nti_receipt_builder/templates/preview.html.erb +24 -0
  33. data/app/views/nti_receipt_builder/templates/print.html.erb +31 -0
  34. data/app/views/nti_receipt_builder/templates/render_preview.turbo_stream.erb +26 -0
  35. data/app/views/nti_receipt_builder/templates/update.turbo_stream.erb +7 -0
  36. data/config/importmap.rb +4 -0
  37. data/lib/generators/nti_receipt_builder/install_generator.rb +49 -0
  38. data/lib/generators/nti_receipt_builder/templates/create_nti_receipt_templates.rb +28 -0
  39. data/lib/generators/nti_receipt_builder/templates/initializer.rb +16 -0
  40. data/lib/generators/nti_receipt_builder/templates/turbo_stream_tags.rb +36 -0
  41. data/lib/nti_receipt_builder/configuration.rb +59 -0
  42. data/lib/nti_receipt_builder/elements.rb +46 -0
  43. data/lib/nti_receipt_builder/engine.rb +32 -0
  44. data/lib/nti_receipt_builder/errors.rb +13 -0
  45. data/lib/nti_receipt_builder/layout_validator.rb +285 -0
  46. data/lib/nti_receipt_builder/presenters/collection.rb +126 -0
  47. data/lib/nti_receipt_builder/presenters/element.rb +68 -0
  48. data/lib/nti_receipt_builder/render_result.rb +18 -0
  49. data/lib/nti_receipt_builder/renderer.rb +78 -0
  50. data/lib/nti_receipt_builder/set_default.rb +24 -0
  51. data/lib/nti_receipt_builder/template_form.rb +60 -0
  52. data/lib/nti_receipt_builder/type_inference.rb +26 -0
  53. data/lib/nti_receipt_builder/variable_definition.rb +74 -0
  54. data/lib/nti_receipt_builder/variable_resolver.rb +55 -0
  55. data/lib/nti_receipt_builder/variables.rb +159 -0
  56. data/lib/nti_receipt_builder/version.rb +5 -0
  57. data/lib/nti_receipt_builder.rb +50 -0
  58. metadata +175 -0
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NtiReceiptBuilder
4
+ # The builder's actions, for inclusion in a host controller that owns routing,
5
+ # authorization and tenancy.
6
+ #
7
+ # class Partner::ReceiptTemplatesController < PartnerController
8
+ # include NtiReceiptBuilder::TemplatesController
9
+ #
10
+ # private
11
+ #
12
+ # def receipt_owner = current_account
13
+ # end
14
+ #
15
+ # Every query runs through `receipt_owner`, so the tenant boundary stays visible in host
16
+ # code rather than in a configured lambda. Hosts may override any action or navigation
17
+ # hook — a method defined in the including class wins over the one mixed in here.
18
+ module TemplatesController
19
+ extend ActiveSupport::Concern
20
+
21
+ VIEW_PREFIX = 'nti_receipt_builder/templates'
22
+
23
+ included do
24
+ before_action :find_receipt_template,
25
+ only: %i[edit update destroy preview render_preview print set_default]
26
+ end
27
+
28
+ # Append the gem's view prefix so bare `render :edit` resolves to the engine's templates.
29
+ # The host's own prefixes stay first, so dropping a receipt_templates/edit.html.erb into the
30
+ # host shadows the gem's copy without any configuration.
31
+ def _prefixes
32
+ @_prefixes ||= super + [VIEW_PREFIX]
33
+ end
34
+
35
+ def new
36
+ @receipt_template = receipt_templates.new(default_template_attributes)
37
+ end
38
+
39
+ def create
40
+ form = TemplateForm.new(receipt_template_params)
41
+
42
+ unless form.valid?
43
+ @errors = form.errors.full_messages
44
+ @receipt_template = receipt_templates.new(default_template_attributes)
45
+ return render_create_failure
46
+ end
47
+
48
+ @receipt_template = receipt_templates.new(form.attributes)
49
+
50
+ if @receipt_template.save
51
+ redirect_to after_create_path(@receipt_template),
52
+ notice: 'Receipt template created! Now build your layout.'
53
+ else
54
+ @errors = @receipt_template.errors.full_messages
55
+ render_create_failure
56
+ end
57
+ end
58
+
59
+ def edit; end
60
+
61
+ def update
62
+ form = TemplateForm.new(receipt_template_params)
63
+
64
+ if form.valid?
65
+ persist_update(form)
66
+ else
67
+ @errors = form.errors.full_messages
68
+ end
69
+
70
+ render :update, status: @errors.empty? ? :ok : :unprocessable_content
71
+ end
72
+
73
+ def destroy
74
+ @receipt_template.destroy
75
+ redirect_to after_destroy_path, notice: 'Receipt template deleted!'
76
+ end
77
+
78
+ def preview
79
+ @render_result = Renderer.new(template: @receipt_template).call
80
+ end
81
+
82
+ def render_preview
83
+ form = TemplateForm.new(receipt_template_params)
84
+
85
+ @draft = @receipt_template.dup
86
+ @draft.assign_attributes(form.attributes.except(:lock_version))
87
+
88
+ if !form.valid?
89
+ @render_result = nil
90
+ @draft_errors = form.errors.full_messages
91
+ elsif @draft.valid?
92
+ @render_result = Renderer.new(template: @draft).call
93
+ @draft_errors = []
94
+ else
95
+ @render_result = nil
96
+ @draft_errors = @draft.errors.full_messages
97
+ end
98
+ end
99
+
100
+ def print
101
+ @render_result = Renderer.new(template: @receipt_template).call
102
+
103
+ render :print, layout: false
104
+ end
105
+
106
+ def set_default
107
+ SetDefault.new(template: @receipt_template).call
108
+
109
+ redirect_to after_set_default_path, notice: 'Default receipt template updated!'
110
+ end
111
+
112
+ private
113
+
114
+ # Hosts MUST implement this. Every query is scoped through it, which is what keeps a
115
+ # multi-tenant host from leaking templates across tenants.
116
+ def receipt_owner
117
+ raise NotImplementedError, "#{self.class.name} must define #receipt_owner"
118
+ end
119
+
120
+ def receipt_templates
121
+ NtiReceiptBuilder.template_class.where(owner: receipt_owner)
122
+ end
123
+
124
+ def find_receipt_template
125
+ @receipt_template = receipt_templates.find_by(id: params[:id])
126
+
127
+ redirect_to after_destroy_path, alert: 'Receipt template not found' unless @receipt_template
128
+ end
129
+
130
+ # The new-template form submits as a Turbo Stream, which patches the error region in place;
131
+ # a plain HTML submit re-renders the whole form. Both carry @errors.
132
+ def render_create_failure
133
+ respond_to do |format|
134
+ format.turbo_stream { render :create, status: :unprocessable_content }
135
+ format.html { render :new, status: :unprocessable_content }
136
+ end
137
+ end
138
+
139
+ def persist_update(form)
140
+ @receipt_template.assign_attributes(form.attributes)
141
+ @errors = @receipt_template.save ? [] : @receipt_template.errors.full_messages
142
+ @receipt_template.reload if @errors.empty?
143
+ rescue ActiveRecord::StaleObjectError
144
+ @errors = ['Someone else has already saved changes to this template. Please reload and try again.']
145
+ end
146
+
147
+ def receipt_template_params
148
+ params.expect(
149
+ receipt_template: %i[
150
+ name paper_size_key paper_width_mm paper_height_mm orientation
151
+ height_mode margin_top_mm margin_right_mm margin_bottom_mm
152
+ margin_left_mm layout lock_version
153
+ ]
154
+ ).to_h.symbolize_keys
155
+ end
156
+
157
+ def default_template_attributes
158
+ {
159
+ paper_size_key: 'roll_80mm', paper_width_mm: 80.0, paper_height_mm: 200.0,
160
+ orientation: 'portrait', height_mode: 'content',
161
+ margin_top_mm: 2, margin_right_mm: 2, margin_bottom_mm: 2, margin_left_mm: 2
162
+ }
163
+ end
164
+
165
+ # Overridable navigation hooks — hosts with named routes should override these.
166
+ def after_create_path(template) = url_for(action: :edit, id: template.id)
167
+ def after_destroy_path = url_for(action: :index)
168
+ def after_set_default_path = url_for(action: :index)
169
+ end
170
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NtiReceiptBuilder
4
+ # View helpers for the shared render partials. These are rendered from more than one host
5
+ # controller — the designer's preview/print and a printed receipt — so they live here rather
6
+ # than in a controller-scoped helper.
7
+ module RenderHelper
8
+ # The paper element's inline size: fixed to the declared dimensions, except in "content"
9
+ # height mode where it grows to fit whatever the renderer computed so nothing gets clipped.
10
+ def paper_style(receipt_template, render_result)
11
+ height_mm = if receipt_template.content_layout?
12
+ [receipt_template.paper_height_mm.to_f,
13
+ render_result.content_height_mm + receipt_template.margin_bottom_mm.to_f].max
14
+ else
15
+ receipt_template.paper_height_mm.to_f
16
+ end
17
+
18
+ "width: #{receipt_template.paper_width_mm.to_f}mm; height: #{height_mm}mm;"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NtiReceiptBuilder
4
+ # A receipt template: paper geometry plus a jsonb layout of positioned elements.
5
+ #
6
+ # Hosts may subclass this to attach their own concerns — audit trail, archivable status,
7
+ # tenancy — and point `config.template_class` at the subclass so the gem's own queries
8
+ # return host instances. `inheritance_column = nil` keeps the subclass sharing this table
9
+ # with no STI type condition and no `type` column to maintain.
10
+ class Template < ActiveRecord::Base
11
+ self.table_name = 'nti_receipt_templates'
12
+ self.inheritance_column = nil
13
+
14
+ belongs_to :owner, polymorphic: true
15
+
16
+ ORIENTATIONS = %w[portrait landscape].freeze
17
+ HEIGHT_MODES = %w[fixed content].freeze
18
+
19
+ MIN_PAPER_DIMENSION_MM = 10.0
20
+ MAX_PAPER_DIMENSION_MM = 1000.0
21
+ MIN_MARGIN_MM = 0.0
22
+ MAX_MARGIN_MM = 100.0
23
+
24
+ PAPER_PRESETS = {
25
+ 'roll_58mm' => { label: '58mm Roll', width_mm: 58.0, height_mm: 297.0, orientation: 'portrait' },
26
+ 'roll_80mm' => { label: '80mm Roll', width_mm: 80.0, height_mm: 297.0, orientation: 'portrait' },
27
+ 'a4' => { label: 'A4', width_mm: 210.0, height_mm: 297.0, orientation: 'portrait' },
28
+ 'letter' => { label: 'Letter', width_mm: 215.9, height_mm: 279.4, orientation: 'portrait' },
29
+ 'custom' => { label: 'Custom', width_mm: 80.0, height_mm: 200.0, orientation: 'portrait' }
30
+ }.freeze
31
+
32
+ validates :name, presence: true, length: { maximum: 120 }
33
+ validates :paper_size_key, presence: true, inclusion: { in: PAPER_PRESETS.keys }
34
+ validates :orientation, presence: true, inclusion: { in: ORIENTATIONS }
35
+ validates :height_mode, presence: true, inclusion: { in: HEIGHT_MODES }
36
+ validates :paper_width_mm, :paper_height_mm,
37
+ presence: true,
38
+ numericality: { greater_than_or_equal_to: MIN_PAPER_DIMENSION_MM,
39
+ less_than_or_equal_to: MAX_PAPER_DIMENSION_MM }
40
+ validates :margin_top_mm, :margin_right_mm, :margin_bottom_mm, :margin_left_mm,
41
+ presence: true,
42
+ numericality: { greater_than_or_equal_to: MIN_MARGIN_MM,
43
+ less_than_or_equal_to: MAX_MARGIN_MM }
44
+ validate :margins_fit_within_paper
45
+ validate :layout_structure
46
+
47
+ def content_layout? = height_mode == 'content'
48
+
49
+ private
50
+
51
+ def margins_fit_within_paper
52
+ return if paper_width_mm.blank? || paper_height_mm.blank?
53
+
54
+ if margin_left_mm.to_f + margin_right_mm.to_f >= paper_width_mm.to_f
55
+ errors.add(:base, 'Left and right margins together exceed the paper width')
56
+ end
57
+
58
+ return if content_layout?
59
+ return if margin_top_mm.to_f + margin_bottom_mm.to_f < paper_height_mm.to_f
60
+
61
+ errors.add(:base, 'Top and bottom margins together exceed the paper height')
62
+ end
63
+
64
+ def layout_structure
65
+ return if layout.blank?
66
+
67
+ LayoutValidator.new(
68
+ layout: layout,
69
+ paper_width_mm: paper_width_mm.to_f,
70
+ paper_height_mm: paper_height_mm.to_f,
71
+ margin_top_mm: margin_top_mm.to_f,
72
+ margin_right_mm: margin_right_mm.to_f,
73
+ margin_bottom_mm: margin_bottom_mm.to_f,
74
+ margin_left_mm: margin_left_mm.to_f,
75
+ height_mode: height_mode
76
+ ).call.each { |message| errors.add(:layout, message) }
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,36 @@
1
+ <div class="nrb-receipt-element nrb-receipt-order-lines"
2
+ style="position: absolute; left: <%= presenter.x_mm %>mm; top: <%= presenter.y_mm %>mm; width: <%= presenter.width_mm %>mm; z-index: <%= presenter.z_index %>; font-size: <%= presenter.font_size_pt %>pt;">
3
+ <table class="nrb-receipt-order-lines-table" style="line-height: <%= presenter.text_line_height_mm %>mm;">
4
+ <colgroup>
5
+ <% presenter.columns.each do |column| %>
6
+ <col style="width: <%= presenter.column_width_pct(column) %>%;">
7
+ <% end %>
8
+ </colgroup>
9
+
10
+ <% if presenter.show_header? %>
11
+ <thead>
12
+ <tr class="nrb-receipt-order-lines-header">
13
+ <% presenter.columns.each do |column| %>
14
+ <th style="text-align: <%= column['align'] %>; padding-bottom: <%= presenter.row_spacing_mm %>mm;"><%= column['label'] %></th>
15
+ <% end %>
16
+ </tr>
17
+ </thead>
18
+ <% end %>
19
+
20
+ <tbody>
21
+ <% if presenter.empty? %>
22
+ <tr class="nrb-receipt-order-lines-empty">
23
+ <td colspan="<%= [presenter.columns.size, 1].max %>" class="text-secondary" style="padding-bottom: <%= presenter.row_spacing_mm %>mm;">No line items</td>
24
+ </tr>
25
+ <% else %>
26
+ <% presenter.rows.each do |row| %>
27
+ <tr>
28
+ <% presenter.columns.each do |column| %>
29
+ <td style="text-align: <%= column['align'] %>; padding-bottom: <%= presenter.row_spacing_mm %>mm;"><%= row[column['key']] %></td>
30
+ <% end %>
31
+ </tr>
32
+ <% end %>
33
+ <% end %>
34
+ </tbody>
35
+ </table>
36
+ </div>
@@ -0,0 +1,5 @@
1
+ <div class="alert alert-warning nrb-receipt-overflow-banner" role="alert">
2
+ <span class="material-symbols-outlined pe-1">warning</span>
3
+ This layout's content exceeds the paper height and may be cut off when printed.
4
+ Consider switching to "Grows with content" height mode or repositioning elements.
5
+ </div>
@@ -0,0 +1,12 @@
1
+ <div class="nrb-receipt-paper-render" style="<%= paper_style(receipt_template, render_result) %>">
2
+ <% render_result.elements.each do |presenter| %>
3
+ <% case presenter.type %>
4
+ <% when 'variable' %>
5
+ <%= render 'nti_receipt_builder/render/variable_element', presenter: presenter %>
6
+ <% when 'text' %>
7
+ <%= render 'nti_receipt_builder/render/text_element', presenter: presenter %>
8
+ <% when 'order_lines' %>
9
+ <%= render 'nti_receipt_builder/render/order_lines_element', presenter: presenter %>
10
+ <% end %>
11
+ <% end %>
12
+ </div>
@@ -0,0 +1 @@
1
+ <div class="nrb-receipt-element" style="<%= presenter.css_style %>"><%= presenter.display_text %></div>
@@ -0,0 +1 @@
1
+ <div class="nrb-receipt-element" style="<%= presenter.css_style %>"><%= presenter.display_text %></div>
@@ -0,0 +1,33 @@
1
+ <div id="receipt-canvas-panel" class="nrb-receipt-panel nrb-receipt-panel--canvas"
2
+ data-controller="nrb-canvas"
3
+ data-nrb-canvas-width-mm-value="<%= @receipt_template.paper_width_mm %>"
4
+ data-nrb-canvas-height-mm-value="<%= @receipt_template.paper_height_mm %>"
5
+ data-nrb-canvas-height-mode-value="<%= @receipt_template.height_mode %>"
6
+ data-nrb-canvas-margin-top-mm-value="<%= @receipt_template.margin_top_mm %>"
7
+ data-nrb-canvas-margin-right-mm-value="<%= @receipt_template.margin_right_mm %>"
8
+ data-nrb-canvas-margin-bottom-mm-value="<%= @receipt_template.margin_bottom_mm %>"
9
+ data-nrb-canvas-margin-left-mm-value="<%= @receipt_template.margin_left_mm %>"
10
+ data-nrb-canvas-grid-mm-value="2"
11
+ data-nrb-canvas-variable-labels-value="<%= NtiReceiptBuilder.variables_class.labels.to_json %>">
12
+ <div class="nrb-receipt-toolbar">
13
+ <h3 class="card-title mb-0">Canvas</h3>
14
+ <div class="nrb-receipt-zoom">
15
+ <span class="material-symbols-outlined">zoom_out</span>
16
+ <input type="range" min="0.5" max="2" step="0.1" value="1"
17
+ data-nrb-canvas-target="zoomRange"
18
+ data-action="input->nrb-canvas#zoomChanged">
19
+ <span class="material-symbols-outlined">zoom_in</span>
20
+ <span class="nrb-receipt-zoom-label" data-nrb-canvas-target="zoomLabel">100%</span>
21
+ </div>
22
+ </div>
23
+
24
+ <div class="nrb-receipt-viewport" data-nrb-canvas-target="viewport"
25
+ data-action="pointerdown->nrb-canvas#paperClicked">
26
+ <div class="nrb-receipt-paper" data-nrb-canvas-target="paper">
27
+ <div class="nrb-receipt-paper-margins" data-nrb-canvas-target="marginGuides"></div>
28
+ <div class="nrb-receipt-paper-elements" data-nrb-canvas-target="elementsLayer"></div>
29
+ </div>
30
+ </div>
31
+
32
+ <p class="text-secondary nrb-receipt-hint" data-nrb-builder-target="elementCount">0 elements</p>
33
+ </div>
@@ -0,0 +1,49 @@
1
+ <div class="nrb-receipt-panel nrb-receipt-panel--elements">
2
+ <h3 class="card-title">Elements</h3>
3
+ <p class="text-secondary nrb-receipt-hint">
4
+ Drag an element onto the receipt, or click to drop it in the middle.
5
+ </p>
6
+
7
+ <div class="nrb-receipt-palette-group">
8
+ <span class="nrb-receipt-palette-label">Variables</span>
9
+ <div class="nrb-receipt-palette">
10
+ <% NtiReceiptBuilder.variables_class.scalar_keys.each do |key| %>
11
+ <button type="button"
12
+ class="nrb-receipt-palette-btn"
13
+ data-nrb-builder-type-param="variable"
14
+ data-nrb-builder-key-param="<%= key %>"
15
+ data-action="pointerdown->nrb-builder#startPaletteDrag">
16
+ <span class="material-symbols-outlined">data_object</span>
17
+ <%= NtiReceiptBuilder.variables_class.labels[key] %>
18
+ </button>
19
+ <% end %>
20
+ </div>
21
+ </div>
22
+
23
+ <div class="nrb-receipt-palette-group">
24
+ <span class="nrb-receipt-palette-label">Blocks</span>
25
+ <div class="nrb-receipt-palette">
26
+ <button type="button"
27
+ class="nrb-receipt-palette-btn"
28
+ data-nrb-builder-type-param="text"
29
+ data-action="pointerdown->nrb-builder#startPaletteDrag">
30
+ <span class="material-symbols-outlined">text_fields</span>
31
+ Custom Text
32
+ </button>
33
+ <% if (collection = NtiReceiptBuilder.variables_class.collection_definition) %>
34
+ <button type="button"
35
+ class="nrb-receipt-palette-btn"
36
+ data-nrb-builder-type-param="order_lines"
37
+ data-action="pointerdown->nrb-builder#startPaletteDrag">
38
+ <span class="material-symbols-outlined">receipt_long</span>
39
+ <%= collection.label %>
40
+ </button>
41
+ <% end %>
42
+ </div>
43
+ </div>
44
+
45
+ <p class="text-secondary nrb-receipt-hint">
46
+ Select an element on the canvas to edit it, or press
47
+ <code>Delete</code> to remove it.
48
+ </p>
49
+ </div>
@@ -0,0 +1,21 @@
1
+ <div class="alert alert-danger alert-dismissible" role="alert">
2
+ <div class="alert-icon">
3
+ <!-- Download SVG icon from http://tabler.io/icons/icon/alert-circle -->
4
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon alert-icon icon-2">
5
+ <path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"></path>
6
+ <path d="M12 8v4"></path>
7
+ <path d="M12 16h.01"></path>
8
+ </svg>
9
+ </div>
10
+ <div>
11
+ <h4 class="alert-heading">Please review the following issue and try again:</h4>
12
+ <div class="alert-description">
13
+ <ul class="alert-list">
14
+ <% errors.each do |message| %>
15
+ <li><%= message %></li>
16
+ <% end %>
17
+ </ul>
18
+ </div>
19
+ </div>
20
+ <a class="btn-close" data-bs-dismiss="alert" aria-label="close"></a>
21
+ </div>
@@ -0,0 +1,2 @@
1
+ <%= hidden_field_tag 'receipt_template[lock_version]', receipt_template.lock_version,
2
+ id: 'receipt_template_lock_version' %>
@@ -0,0 +1,15 @@
1
+ <%#
2
+ Assets for the standalone print document. Both helpers are guarded so a host without
3
+ propshaft or importmap still renders a correct receipt — it just loses auto-print.
4
+
5
+ To control this precisely, shadow the partial from your own controller's view directory,
6
+ e.g. app/views/partner/receipt_templates/_print_assets.html.erb. Host prefixes are searched
7
+ before the gem's, so your copy wins with no configuration.
8
+ %>
9
+ <% sheets = Array(NtiReceiptBuilder.config.print_stylesheet).compact %>
10
+ <% if sheets.any? && respond_to?(:stylesheet_link_tag) %>
11
+ <%= stylesheet_link_tag(*sheets) %>
12
+ <% end %>
13
+ <% if respond_to?(:javascript_importmap_tags) %>
14
+ <%= javascript_importmap_tags %>
15
+ <% end %>
@@ -0,0 +1,8 @@
1
+ <div id="receipt-properties-panel" class="nrb-receipt-panel nrb-receipt-panel--properties"
2
+ data-controller="nrb-properties"
3
+ data-nrb-properties-scalar-variables-value="<%= NtiReceiptBuilder.variables_class.scalar_keys.index_with { |key| NtiReceiptBuilder.variables_class.labels[key] }.to_json %>">
4
+ <h3 class="card-title">Properties</h3>
5
+ <div data-nrb-properties-target="content">
6
+ <p class="text-secondary nrb-receipt-hint">Select an element on the canvas to edit its properties.</p>
7
+ </div>
8
+ </div>
@@ -0,0 +1,130 @@
1
+ <div class="nrb-receipt-panel nrb-receipt-panel--settings">
2
+ <h3 class="card-title">Settings</h3>
3
+
4
+ <div class="form-group mb-3">
5
+ <%= f.label :name, 'Template Name', class: 'form-label' %>
6
+ <%= f.text_field :name, class: 'form-control' %>
7
+ </div>
8
+
9
+ <div class="form-group mb-3">
10
+ <%= f.label :paper_size_key, 'Paper Size', class: 'form-label' %>
11
+ <%= f.select :paper_size_key,
12
+ NtiReceiptBuilder::Template::PAPER_PRESETS.map { |key, preset| [preset[:label], key] },
13
+ {},
14
+ class: 'form-select',
15
+ data: {
16
+ nrb_builder_target: 'paperSizeSelect',
17
+ action: 'change->nrb-builder#paperPresetChanged'
18
+ } %>
19
+ </div>
20
+
21
+ <div class="row g-3">
22
+ <div class="col">
23
+ <div class="form-group mb-3">
24
+ <%= f.label :paper_width_mm, 'Width (mm)', class: 'form-label' %>
25
+ <%= f.number_field :paper_width_mm, step: 0.1,
26
+ min: NtiReceiptBuilder::Template::MIN_PAPER_DIMENSION_MM,
27
+ max: NtiReceiptBuilder::Template::MAX_PAPER_DIMENSION_MM,
28
+ class: 'form-control',
29
+ data: {
30
+ nrb_builder_target: 'widthInput',
31
+ action: 'input->nrb-builder#settingsChanged'
32
+ } %>
33
+ </div>
34
+ </div>
35
+ <div class="col">
36
+ <div class="form-group mb-3">
37
+ <%= f.label :paper_height_mm, 'Height (mm)', class: 'form-label' %>
38
+ <%= f.number_field :paper_height_mm, step: 0.1,
39
+ min: NtiReceiptBuilder::Template::MIN_PAPER_DIMENSION_MM,
40
+ max: NtiReceiptBuilder::Template::MAX_PAPER_DIMENSION_MM,
41
+ class: 'form-control',
42
+ data: {
43
+ nrb_builder_target: 'heightInput',
44
+ action: 'input->nrb-builder#settingsChanged'
45
+ } %>
46
+ </div>
47
+ </div>
48
+ </div>
49
+
50
+ <div class="row g-3">
51
+ <div class="col">
52
+ <div class="form-group mb-3">
53
+ <%= f.label :orientation, 'Orientation', class: 'form-label' %>
54
+ <%= f.select :orientation,
55
+ NtiReceiptBuilder::Template::ORIENTATIONS.map { |value| [value.titleize, value] },
56
+ {},
57
+ class: 'form-select',
58
+ data: {
59
+ nrb_builder_target: 'orientationSelect',
60
+ action: 'change->nrb-builder#settingsChanged'
61
+ } %>
62
+ </div>
63
+ </div>
64
+ <div class="col">
65
+ <div class="form-group mb-3">
66
+ <%= f.label :height_mode, 'Height Mode', class: 'form-label' %>
67
+ <%= f.select :height_mode,
68
+ [['Fixed Height', 'fixed'], ['Grows With Content', 'content']],
69
+ {},
70
+ class: 'form-select',
71
+ data: {
72
+ nrb_builder_target: 'heightModeSelect',
73
+ action: 'change->nrb-builder#settingsChanged'
74
+ } %>
75
+ </div>
76
+ </div>
77
+ </div>
78
+
79
+ <label class="form-label">Margins (mm)</label>
80
+ <div class="row g-3">
81
+ <div class="col-6">
82
+ <div class="form-group mb-3">
83
+ <%= f.label :margin_top_mm, 'Top', class: 'form-label' %>
84
+ <%= f.number_field :margin_top_mm, step: 0.1,
85
+ min: NtiReceiptBuilder::Template::MIN_MARGIN_MM, max: NtiReceiptBuilder::Template::MAX_MARGIN_MM,
86
+ class: 'form-control',
87
+ data: {
88
+ nrb_builder_target: 'marginTopInput',
89
+ action: 'input->nrb-builder#settingsChanged'
90
+ } %>
91
+ </div>
92
+ </div>
93
+ <div class="col-6">
94
+ <div class="form-group mb-3">
95
+ <%= f.label :margin_right_mm, 'Right', class: 'form-label' %>
96
+ <%= f.number_field :margin_right_mm, step: 0.1,
97
+ min: NtiReceiptBuilder::Template::MIN_MARGIN_MM, max: NtiReceiptBuilder::Template::MAX_MARGIN_MM,
98
+ class: 'form-control',
99
+ data: {
100
+ nrb_builder_target: 'marginRightInput',
101
+ action: 'input->nrb-builder#settingsChanged'
102
+ } %>
103
+ </div>
104
+ </div>
105
+ <div class="col-6">
106
+ <div class="form-group mb-3">
107
+ <%= f.label :margin_bottom_mm, 'Bottom', class: 'form-label' %>
108
+ <%= f.number_field :margin_bottom_mm, step: 0.1,
109
+ min: NtiReceiptBuilder::Template::MIN_MARGIN_MM, max: NtiReceiptBuilder::Template::MAX_MARGIN_MM,
110
+ class: 'form-control',
111
+ data: {
112
+ nrb_builder_target: 'marginBottomInput',
113
+ action: 'input->nrb-builder#settingsChanged'
114
+ } %>
115
+ </div>
116
+ </div>
117
+ <div class="col-6">
118
+ <div class="form-group mb-3">
119
+ <%= f.label :margin_left_mm, 'Left', class: 'form-label' %>
120
+ <%= f.number_field :margin_left_mm, step: 0.1,
121
+ min: NtiReceiptBuilder::Template::MIN_MARGIN_MM, max: NtiReceiptBuilder::Template::MAX_MARGIN_MM,
122
+ class: 'form-control',
123
+ data: {
124
+ nrb_builder_target: 'marginLeftInput',
125
+ action: 'input->nrb-builder#settingsChanged'
126
+ } %>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ </div>
@@ -0,0 +1,3 @@
1
+ <% if @errors.any? %>
2
+ <%= turbo_stream.update 'errors', partial: 'nti_receipt_builder/templates/errors', locals: { errors: @errors } %>
3
+ <% end %>
@@ -0,0 +1,54 @@
1
+ <% content_for :title, "Edit #{@receipt_template.name}" %>
2
+ <% content_for :cardless, true %>
3
+
4
+ <%= form_with model: @receipt_template, url: url_for(action: :update, id: @receipt_template.id), method: :patch,
5
+ scope: :receipt_template,
6
+ data: {
7
+ controller: 'nrb-builder',
8
+ nrb_builder_layout_value: @receipt_template.layout.to_json,
9
+ nrb_builder_presets_value: NtiReceiptBuilder::Template::PAPER_PRESETS.to_json,
10
+ nrb_builder_collection_columns_value:
11
+ (NtiReceiptBuilder.variables_class.collection_definition&.default_columns_config || []).to_json,
12
+ nrb_builder_nrb_canvas_outlet: '#receipt-canvas-panel',
13
+ nrb_builder_nrb_properties_outlet: '#receipt-properties-panel',
14
+ action: 'formdata->nrb-builder#writeLayoutToFormData ' \
15
+ 'receipt-element:moved->nrb-builder#elementMoved ' \
16
+ 'receipt-element:resized->nrb-builder#elementResized ' \
17
+ 'receipt-element:selected->nrb-builder#elementSelected ' \
18
+ 'receipt-canvas:deselect->nrb-builder#deselect ' \
19
+ 'receipt-properties:changed->nrb-builder#propertiesChanged ' \
20
+ 'receipt-properties:delete-requested->nrb-builder#removeSelected'
21
+ } do |f| %>
22
+
23
+ <div class="nrb-receipt-topbar">
24
+ <div>
25
+ <span class="nrb-receipt-topbar-name"><%= @receipt_template.name %></span>
26
+ <span class="text-secondary"><%= @receipt_template.paper_width_mm %>mm × <%= @receipt_template.paper_height_mm %>mm</span>
27
+ </div>
28
+ <div class="d-flex justify-content-end gap-2">
29
+ <%= f.button 'Live Preview', type: 'submit', class: 'btn btn-outline-secondary',
30
+ formaction: url_for(action: :render_preview, id: @receipt_template.id), formmethod: :post %>
31
+ <%= link_to url_for(action: :preview, id: @receipt_template.id),
32
+ class: 'btn btn-outline-secondary', target: '_blank', rel: 'noopener' do %>
33
+ <span class="material-symbols-outlined pe-1">visibility</span>Preview
34
+ <% end %>
35
+ <%= link_to 'Back to Templates', url_for(action: :index), class: 'btn btn-secondary' %>
36
+ <%= f.submit 'Save', class: 'btn btn-primary' %>
37
+ </div>
38
+ </div>
39
+
40
+ <div id="errors"></div>
41
+
42
+ <%= f.hidden_field :layout, value: @receipt_template.layout.to_json,
43
+ data: { nrb_builder_target: 'layoutField' } %>
44
+ <span id="lock_version_field"><%= render 'nti_receipt_builder/templates/lock_version_field', receipt_template: @receipt_template %></span>
45
+
46
+ <div class="nrb-receipt-builder">
47
+ <div class="nrb-receipt-builder-side">
48
+ <%= render 'nti_receipt_builder/templates/elements_panel' %>
49
+ <%= render 'nti_receipt_builder/templates/settings_panel', f: f %>
50
+ </div>
51
+ <%= render 'nti_receipt_builder/templates/canvas' %>
52
+ <%= render 'nti_receipt_builder/templates/properties_panel' %>
53
+ </div>
54
+ <% end %>