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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +272 -0
- data/Rakefile +12 -0
- data/app/assets/javascripts/nti_receipt_builder/index.js +16 -0
- data/app/assets/javascripts/nti_receipt_builder/receipt_builder_controller.js +297 -0
- data/app/assets/javascripts/nti_receipt_builder/receipt_canvas_controller.js +279 -0
- data/app/assets/javascripts/nti_receipt_builder/receipt_draggable_controller.js +154 -0
- data/app/assets/javascripts/nti_receipt_builder/receipt_print_controller.js +24 -0
- data/app/assets/javascripts/nti_receipt_builder/receipt_properties_controller.js +297 -0
- data/app/assets/stylesheets/nti_receipt_builder.css +238 -0
- data/app/controllers/concerns/nti_receipt_builder/templates_controller.rb +170 -0
- data/app/helpers/nti_receipt_builder/render_helper.rb +21 -0
- data/app/models/nti_receipt_builder/template.rb +79 -0
- data/app/views/nti_receipt_builder/render/_order_lines_element.html.erb +36 -0
- data/app/views/nti_receipt_builder/render/_overflow_banner.html.erb +5 -0
- data/app/views/nti_receipt_builder/render/_paper.html.erb +12 -0
- data/app/views/nti_receipt_builder/render/_text_element.html.erb +1 -0
- data/app/views/nti_receipt_builder/render/_variable_element.html.erb +1 -0
- data/app/views/nti_receipt_builder/templates/_canvas.html.erb +33 -0
- data/app/views/nti_receipt_builder/templates/_elements_panel.html.erb +49 -0
- data/app/views/nti_receipt_builder/templates/_errors.html.erb +21 -0
- data/app/views/nti_receipt_builder/templates/_lock_version_field.html.erb +2 -0
- data/app/views/nti_receipt_builder/templates/_print_assets.html.erb +15 -0
- data/app/views/nti_receipt_builder/templates/_properties_panel.html.erb +8 -0
- data/app/views/nti_receipt_builder/templates/_settings_panel.html.erb +130 -0
- data/app/views/nti_receipt_builder/templates/create.turbo_stream.erb +3 -0
- data/app/views/nti_receipt_builder/templates/edit.html.erb +54 -0
- data/app/views/nti_receipt_builder/templates/new.html.erb +115 -0
- data/app/views/nti_receipt_builder/templates/preview.html.erb +24 -0
- data/app/views/nti_receipt_builder/templates/print.html.erb +31 -0
- data/app/views/nti_receipt_builder/templates/render_preview.turbo_stream.erb +26 -0
- data/app/views/nti_receipt_builder/templates/update.turbo_stream.erb +7 -0
- data/config/importmap.rb +4 -0
- data/lib/generators/nti_receipt_builder/install_generator.rb +49 -0
- data/lib/generators/nti_receipt_builder/templates/create_nti_receipt_templates.rb +28 -0
- data/lib/generators/nti_receipt_builder/templates/initializer.rb +16 -0
- data/lib/generators/nti_receipt_builder/templates/turbo_stream_tags.rb +36 -0
- data/lib/nti_receipt_builder/configuration.rb +59 -0
- data/lib/nti_receipt_builder/elements.rb +46 -0
- data/lib/nti_receipt_builder/engine.rb +32 -0
- data/lib/nti_receipt_builder/errors.rb +13 -0
- data/lib/nti_receipt_builder/layout_validator.rb +285 -0
- data/lib/nti_receipt_builder/presenters/collection.rb +126 -0
- data/lib/nti_receipt_builder/presenters/element.rb +68 -0
- data/lib/nti_receipt_builder/render_result.rb +18 -0
- data/lib/nti_receipt_builder/renderer.rb +78 -0
- data/lib/nti_receipt_builder/set_default.rb +24 -0
- data/lib/nti_receipt_builder/template_form.rb +60 -0
- data/lib/nti_receipt_builder/type_inference.rb +26 -0
- data/lib/nti_receipt_builder/variable_definition.rb +74 -0
- data/lib/nti_receipt_builder/variable_resolver.rb +55 -0
- data/lib/nti_receipt_builder/variables.rb +159 -0
- data/lib/nti_receipt_builder/version.rb +5 -0
- data/lib/nti_receipt_builder.rb +50 -0
- metadata +175 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4493131823c3d523ede3d5e0c57b77caf81245f191dca1019ba5536cdf1dbc55
|
|
4
|
+
data.tar.gz: ce594bac64cd068754246e4cf62b90eed741e87c3dc5224dcdd570da267616db
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e8b7e1012da9720f4bba538fd3363a93fe4d349c75f6fd8de4c7d66c5dbc094c08b62603e643ebd19579494af386ed9931e9551dab09193cf2becf610006bbce
|
|
7
|
+
data.tar.gz: 916a9a7b7f8d7101e663227821564cd5d9391352189c95e465e16d5bd41069cbbcef1cfc915c17dcd481a70f7aac3e7ab37112619439b9b4c33adbc16ec5f61b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-08-02
|
|
4
|
+
|
|
5
|
+
Initial release, extracted from SalesWiz's receipt builder.
|
|
6
|
+
|
|
7
|
+
- `NtiReceiptBuilder::Variables` — host-declared receipt vocabulary. `MAPPINGS` supplies keys
|
|
8
|
+
and value methods; label, formatting type and preview sample are derived and overridable per
|
|
9
|
+
key with `variable`. The key declaring `columns:` is the collection.
|
|
10
|
+
- `NtiReceiptBuilder::Template` — paper geometry, validations and jsonb layout, with
|
|
11
|
+
`inheritance_column = nil` so hosts can subclass and share the table without STI.
|
|
12
|
+
- `NtiReceiptBuilder::LayoutValidator` — structural and whitelist validation driven by the
|
|
13
|
+
host's declared variable and column keys.
|
|
14
|
+
- `NtiReceiptBuilder::Renderer` — one render path for both the designer preview and printed
|
|
15
|
+
output; omit `receipt_object:` to render declared samples.
|
|
16
|
+
- `NtiReceiptBuilder::TemplatesController` — the builder's actions as a concern, scoped through
|
|
17
|
+
an abstract `receipt_owner` the host must define.
|
|
18
|
+
- Builder UI: five Stimulus controllers, builder and print views, and a `nrb-receipt-*`
|
|
19
|
+
stylesheet themable via `--nrb-accent`.
|
|
20
|
+
- `rails generate nti_receipt_builder:install` — initializer, migration, and Turbo Stream
|
|
21
|
+
helpers for hosts that lack them.
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"nti_receipt_builder" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["denmarkmeralpis@gmail.com"](mailto:"denmarkmeralpis@gmail.com").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Den Meralpis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# NtiReceiptBuilder
|
|
2
|
+
|
|
3
|
+
A drag-and-drop receipt template designer and millimetre-accurate renderer, packaged as a
|
|
4
|
+
Rails engine.
|
|
5
|
+
|
|
6
|
+
The gem ships the whole vertical — model, layout validation, renderer, print views, and a
|
|
7
|
+
Stimulus builder UI — but carries **no domain vocabulary of its own**. Your application
|
|
8
|
+
declares a `Variables` subclass mapping receipt placeholders to methods on whatever object it
|
|
9
|
+
prints receipts for, and the gem drives the designer, the validator and the renderer from it.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Gemfile
|
|
15
|
+
gem 'nti_receipt_builder'
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bundle install
|
|
20
|
+
bin/rails generate nti_receipt_builder:install
|
|
21
|
+
bin/rails db:migrate
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The generator creates:
|
|
25
|
+
|
|
26
|
+
- `config/initializers/nti_receipt_builder.rb`
|
|
27
|
+
- a migration creating `nti_receipt_templates`
|
|
28
|
+
- `config/initializers/turbo_stream_tags.rb` — **skipped** if your app already defines
|
|
29
|
+
`open_modal` on `Turbo::Streams::TagBuilder`
|
|
30
|
+
|
|
31
|
+
## Declaring your variables
|
|
32
|
+
|
|
33
|
+
`MAPPINGS` supplies the placeholder keys and the method that produces each value. Everything
|
|
34
|
+
else is derived, and overridden per key with `variable` only where the derived default is
|
|
35
|
+
wrong.
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class OrderReceiptVariables < NtiReceiptBuilder::Variables
|
|
39
|
+
MAPPINGS = {
|
|
40
|
+
'CUSTOMER_NAME' => 'customer_name',
|
|
41
|
+
'ORDER_REFERENCE' => 'order_reference',
|
|
42
|
+
'ORDER_SUBTOTAL' => 'order_subtotal',
|
|
43
|
+
'ORDER_DATETIME' => 'order_datetime',
|
|
44
|
+
'ORDER_LINES' => 'order_lines'
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
variable 'CUSTOMER_NAME', sample: 'Juan Dela Cruz'
|
|
48
|
+
variable 'ORDER_SUBTOTAL', sample: BigDecimal('275.00')
|
|
49
|
+
variable 'ORDER_DATETIME', label: 'Order Date/Time',
|
|
50
|
+
sample: -> { Time.zone.local(2026, 7, 31, 14, 32) }
|
|
51
|
+
variable 'ORDER_LINES', columns: {
|
|
52
|
+
'description' => { label: 'Description', width_mm: 24 },
|
|
53
|
+
'quantity' => { label: 'Qty', width_mm: 8, align: 'center' },
|
|
54
|
+
'unit_price' => { label: 'Unit Price', width_mm: 12, align: 'right' },
|
|
55
|
+
'line_total' => { label: 'Total', width_mm: 12, align: 'right' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def customer_name = receipt_object.customer.name
|
|
59
|
+
def order_reference = receipt_object.reference
|
|
60
|
+
def order_subtotal = receipt_object.subtotal
|
|
61
|
+
def order_datetime = receipt_object.created_at
|
|
62
|
+
|
|
63
|
+
def order_lines
|
|
64
|
+
receipt_object.order_lines.order(:order_line_number).map do |line|
|
|
65
|
+
{ 'description' => line.product_name, 'quantity' => line.quantity,
|
|
66
|
+
'unit_price' => line.retail_price, 'line_total' => line.subtotal }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`receipt_object` is whatever you pass to the renderer — an order, an invoice, a booking.
|
|
73
|
+
|
|
74
|
+
### What is derived, and what you can override
|
|
75
|
+
|
|
76
|
+
| Aspect | Derived default | Override |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| Label | `key.titleize` — `'STORE_NAME'` → `'Store Name'` | `label:` |
|
|
79
|
+
| Formatting type | the value's class at render time: `BigDecimal` → currency, responds to `strftime` → datetime, `Array` → collection, other `Numeric` → plain, else string | `type:` |
|
|
80
|
+
| Preview sample | the label as a string; a collection gets three filler rows | `sample:` — a value or a lambda |
|
|
81
|
+
| Collection | the one key declaring `columns:` **is** the collection | — |
|
|
82
|
+
|
|
83
|
+
Type is decided at format time, not declaration time, so real data and preview samples travel
|
|
84
|
+
the same path. `type:` exists for the cases inference cannot get right — a pre-formatted
|
|
85
|
+
`String` that should be currency, say.
|
|
86
|
+
|
|
87
|
+
Column options are `label:`, `type:`, `width_mm:` and `align:`, all optional. They whitelist
|
|
88
|
+
the column keys a saved layout may use, seed the builder when a designer drops a fresh table,
|
|
89
|
+
and decide cell formatting.
|
|
90
|
+
|
|
91
|
+
A malformed class fails when its schema is first read — missing `MAPPINGS`, a mapped method
|
|
92
|
+
that does not exist, a `variable` call for a key absent from `MAPPINGS`, or two keys declaring
|
|
93
|
+
`columns:` all raise `NtiReceiptBuilder::InvalidVariablesError`. Typos surface in development
|
|
94
|
+
rather than at print time.
|
|
95
|
+
|
|
96
|
+
An error raised inside a mapping method propagates. That is deliberate: a receipt printing
|
|
97
|
+
silently without its total is worse than a visible failure.
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
NtiReceiptBuilder.configure do |config|
|
|
103
|
+
config.variables_class = 'OrderReceiptVariables' # required
|
|
104
|
+
config.template_class = 'ReceiptTemplate' # default 'NtiReceiptBuilder::Template'
|
|
105
|
+
config.currency_unit = '₱'
|
|
106
|
+
config.datetime_format = '%m/%d/%Y %I:%M %p'
|
|
107
|
+
config.print_stylesheet = 'nti_receipt_builder' # nil to load none
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Class references may be given as a `Class` or a `String`; either way the gem retains only the
|
|
112
|
+
**name** and resolves it per access. Holding a reloadable application class in configuration
|
|
113
|
+
would pin that class and its constant tree past every development reload, and would serve a
|
|
114
|
+
stale class after your code reloads.
|
|
115
|
+
|
|
116
|
+
## Wiring a controller
|
|
117
|
+
|
|
118
|
+
The gem ships its actions as a concern. Your controller keeps routing, authorization and
|
|
119
|
+
tenant scoping.
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
class Partner::ReceiptTemplatesController < PartnerController
|
|
123
|
+
include NtiReceiptBuilder::TemplatesController
|
|
124
|
+
|
|
125
|
+
def index
|
|
126
|
+
# yours — datatable, decorators, whatever you already use
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def receipt_owner = current_account
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
# config/routes.rb
|
|
137
|
+
resources :receipt_templates do
|
|
138
|
+
member do
|
|
139
|
+
get :preview
|
|
140
|
+
post :render_preview
|
|
141
|
+
get :print
|
|
142
|
+
patch :set_default
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`receipt_owner` is mandatory: every query the concern makes is scoped through it, so the
|
|
148
|
+
tenant boundary stays visible in your code rather than in a configured lambda. A controller
|
|
149
|
+
that omits it raises `NotImplementedError` instead of running an unscoped query.
|
|
150
|
+
|
|
151
|
+
The concern provides `new`, `create`, `edit`, `update`, `destroy`, `preview`,
|
|
152
|
+
`render_preview`, `print` and `set_default`. Define any of them in your controller to take
|
|
153
|
+
over — a method on the including class wins. SalesWiz overrides `destroy`, for instance, to
|
|
154
|
+
archive rather than delete. The navigation hooks `after_create_path`, `after_destroy_path` and
|
|
155
|
+
`after_set_default_path` are overridable too, and worth overriding if you have named routes.
|
|
156
|
+
|
|
157
|
+
### Subclassing the model
|
|
158
|
+
|
|
159
|
+
Point `template_class` at a subclass to attach your own concerns while sharing the table:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
class ReceiptTemplate < NtiReceiptBuilder::Template
|
|
163
|
+
# See the note below — these two lines go together.
|
|
164
|
+
def self.base_class = self
|
|
165
|
+
self.table_name = 'nti_receipt_templates'
|
|
166
|
+
|
|
167
|
+
include Auditable
|
|
168
|
+
has_assignable_status
|
|
169
|
+
|
|
170
|
+
validates :is_default, uniqueness: { scope: %i[owner_type owner_id],
|
|
171
|
+
conditions: -> { where(is_default: true) } }
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The parent sets `inheritance_column = nil`, so the subclass shares `nti_receipt_templates`
|
|
176
|
+
with no STI type condition and no `type` column to maintain. Host-specific columns go in your
|
|
177
|
+
own migration against the same table.
|
|
178
|
+
|
|
179
|
+
**If you subclass, override `base_class` and re-declare the table.** Rails resolves
|
|
180
|
+
`base_class` up the superclass chain, so without the override it returns
|
|
181
|
+
`NtiReceiptBuilder::Template`. Anything that records a class name from `base_class` —
|
|
182
|
+
PaperTrail's `item_type`, polymorphic `*_type` columns — would then store the gem's class name
|
|
183
|
+
rather than yours, and your own queries would not find those rows. Returning `self` is safe
|
|
184
|
+
precisely because there is no STI to disturb.
|
|
185
|
+
|
|
186
|
+
The two lines are a pair: once the subclass is its own `base_class`, Rails derives the table
|
|
187
|
+
name from *it*, which would give `receipt_templates`. Naming the engine's table explicitly
|
|
188
|
+
keeps them on the same rows.
|
|
189
|
+
|
|
190
|
+
### Overriding a view
|
|
191
|
+
|
|
192
|
+
Host view prefixes are searched before the gem's, so dropping
|
|
193
|
+
`app/views/partner/receipt_templates/edit.html.erb` into your app shadows the gem's copy with
|
|
194
|
+
no configuration. The same trick works for `_print_assets.html.erb` if you want exact control
|
|
195
|
+
over what the standalone print document loads.
|
|
196
|
+
|
|
197
|
+
## Rendering a receipt
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
@render_result = NtiReceiptBuilder::Renderer.new(
|
|
201
|
+
template: template, receipt_object: order
|
|
202
|
+
).call
|
|
203
|
+
|
|
204
|
+
render template: 'nti_receipt_builder/templates/print', layout: false
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Omit `receipt_object:` and the renderer uses your declared samples without calling a single
|
|
208
|
+
mapping method — that is what the designer's preview does.
|
|
209
|
+
|
|
210
|
+
`RenderResult` exposes `elements` (presenters in paint order), `overflow?` and
|
|
211
|
+
`content_height_mm`. Preload whatever associations your scalar mappings traverse; the gem
|
|
212
|
+
cannot know them.
|
|
213
|
+
|
|
214
|
+
## JavaScript and CSS
|
|
215
|
+
|
|
216
|
+
```js
|
|
217
|
+
// app/javascript/controllers/index.js
|
|
218
|
+
import { registerNtiReceiptBuilder } from 'nti_receipt_builder'
|
|
219
|
+
|
|
220
|
+
registerNtiReceiptBuilder(application)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```css
|
|
224
|
+
/* app/assets/stylesheets/application.css */
|
|
225
|
+
@import 'nti_receipt_builder';
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Identifiers are `nrb-builder`, `nrb-canvas`, `nrb-draggable`, `nrb-print` and `nrb-properties`.
|
|
229
|
+
Set `--nrb-accent` and `--nrb-accent-rgb` to tint the builder's selection and palette
|
|
230
|
+
affordances; both fall back to Tabler's primary.
|
|
231
|
+
|
|
232
|
+
## Host requirements
|
|
233
|
+
|
|
234
|
+
1. **Tabler 1.x with Bootstrap JS** in the layout — the builder chrome uses Tabler's card,
|
|
235
|
+
button and form classes, and the preview modal uses Bootstrap's dismiss behaviour.
|
|
236
|
+
2. **`Turbo::Streams::TagBuilder` responding to** `open_modal(size:, &block)`, `close_modal`,
|
|
237
|
+
`toast(message, type:)` and `reload_datatable(id)`, with matching `turbo_stream` action
|
|
238
|
+
handlers in your JavaScript. The install generator supplies these if you lack them.
|
|
239
|
+
3. **`receipt_owner`** on the controller including the concern.
|
|
240
|
+
4. Optional: a global `window.Toast` with `.warning(message)` for the element-limit notice.
|
|
241
|
+
The builder degrades silently without it.
|
|
242
|
+
5. Optional: the `material-symbols-outlined` icon font, used for palette and toolbar icons.
|
|
243
|
+
|
|
244
|
+
## Limits
|
|
245
|
+
|
|
246
|
+
Enforced by `LayoutValidator` before anything persists, which is what bounds the memory a
|
|
247
|
+
single template can consume:
|
|
248
|
+
|
|
249
|
+
| Limit | Value |
|
|
250
|
+
|---|---|
|
|
251
|
+
| Elements per template | 60 |
|
|
252
|
+
| Minimum element dimension | 1.0mm |
|
|
253
|
+
| Text length | 500 |
|
|
254
|
+
| Fallback / prefix / suffix | 200 / 50 / 50 |
|
|
255
|
+
| Order-line columns | 1–6 |
|
|
256
|
+
| Font size | 6–72pt |
|
|
257
|
+
| `z-index` | 0–1000 |
|
|
258
|
+
|
|
259
|
+
## Development
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
bin/setup
|
|
263
|
+
createdb nti_receipt_builder_test # specs need PostgreSQL — layout is jsonb
|
|
264
|
+
bundle exec rspec
|
|
265
|
+
bundle exec rubocop
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Specs run against a minimal dummy Rails app in `spec/dummy`.
|
|
269
|
+
|
|
270
|
+
## License
|
|
271
|
+
|
|
272
|
+
MIT.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import ReceiptBuilderController from "nti_receipt_builder/receipt_builder_controller"
|
|
2
|
+
import ReceiptCanvasController from "nti_receipt_builder/receipt_canvas_controller"
|
|
3
|
+
import ReceiptDraggableController from "nti_receipt_builder/receipt_draggable_controller"
|
|
4
|
+
import ReceiptPrintController from "nti_receipt_builder/receipt_print_controller"
|
|
5
|
+
import ReceiptPropertiesController from "nti_receipt_builder/receipt_properties_controller"
|
|
6
|
+
|
|
7
|
+
// Identifiers are registered explicitly rather than derived from file paths, so they stay short
|
|
8
|
+
// and stable regardless of where a host mounts the gem's JavaScript. They must match the
|
|
9
|
+
// data-controller attributes in the gem's views.
|
|
10
|
+
export function registerNtiReceiptBuilder(application) {
|
|
11
|
+
application.register("nrb-builder", ReceiptBuilderController)
|
|
12
|
+
application.register("nrb-canvas", ReceiptCanvasController)
|
|
13
|
+
application.register("nrb-draggable", ReceiptDraggableController)
|
|
14
|
+
application.register("nrb-print", ReceiptPrintController)
|
|
15
|
+
application.register("nrb-properties", ReceiptPropertiesController)
|
|
16
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
const MAX_ELEMENTS = 60
|
|
4
|
+
const Z_INDEX_MAX = 1000
|
|
5
|
+
|
|
6
|
+
const DEFAULT_DIMENSIONS = {
|
|
7
|
+
variable: { widthMm: 40, heightMm: 5 },
|
|
8
|
+
text: { widthMm: 40, heightMm: 6 },
|
|
9
|
+
order_lines: { widthMm: 60, heightMm: 20 }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Connects to data-controller="nrb-builder"
|
|
13
|
+
export default class extends Controller {
|
|
14
|
+
static targets = [
|
|
15
|
+
"layoutField",
|
|
16
|
+
"elementCount",
|
|
17
|
+
"paperSizeSelect",
|
|
18
|
+
"widthInput",
|
|
19
|
+
"heightInput",
|
|
20
|
+
"orientationSelect",
|
|
21
|
+
"heightModeSelect",
|
|
22
|
+
"marginTopInput",
|
|
23
|
+
"marginRightInput",
|
|
24
|
+
"marginBottomInput",
|
|
25
|
+
"marginLeftInput"
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
static values = { layout: Array, presets: Object, collectionColumns: Array }
|
|
29
|
+
static outlets = ["nrb-canvas", "nrb-properties"]
|
|
30
|
+
|
|
31
|
+
initialize() {
|
|
32
|
+
this.selectedId = null
|
|
33
|
+
this.paletteDragHandler = null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Stimulus calls this on connect and after every assignment to layoutValue — adding, patching,
|
|
37
|
+
// moving, resizing and deleting all route through one of those, so the hidden field is never
|
|
38
|
+
// stale.
|
|
39
|
+
//
|
|
40
|
+
// Serialising here rather than on submit is deliberate and load-bearing. Turbo builds the
|
|
41
|
+
// request body in the FormSubmission constructor, which runs BEFORE turbo:submit-start is
|
|
42
|
+
// dispatched from requestStarted(). A listener on that event writes the field after the body
|
|
43
|
+
// has already been snapshotted, so every edit was dropped and the server silently re-saved the
|
|
44
|
+
// layout the page was rendered with.
|
|
45
|
+
layoutValueChanged() {
|
|
46
|
+
this.serializeLayout()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
disconnect() {
|
|
50
|
+
this.cancelPaletteDrag()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
nrbCanvasOutletConnected() {
|
|
54
|
+
this.renderAll()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Palette drag/drop
|
|
58
|
+
|
|
59
|
+
// The pointerup is attached to `document` because the gesture can end anywhere on the page.
|
|
60
|
+
// `{ once: true }` alone is not enough: if the pointerup never arrives — Turbo navigates away
|
|
61
|
+
// mid-gesture, or the pointer is released outside the document — the handler stays on
|
|
62
|
+
// `document` holding a closure over this controller, leaking one listener and one controller
|
|
63
|
+
// per visit. So keep a reference and detach it in disconnect().
|
|
64
|
+
cancelPaletteDrag() {
|
|
65
|
+
if (!this.paletteDragHandler) return
|
|
66
|
+
|
|
67
|
+
document.removeEventListener("pointerup", this.paletteDragHandler)
|
|
68
|
+
this.paletteDragHandler = null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
startPaletteDrag(event) {
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
const type = event.params.type
|
|
74
|
+
const key = event.params.key || null
|
|
75
|
+
|
|
76
|
+
this.cancelPaletteDrag()
|
|
77
|
+
this.paletteDragHandler = (upEvent) => {
|
|
78
|
+
this.paletteDragHandler = null
|
|
79
|
+
this.finishPaletteDrag(upEvent, type, key)
|
|
80
|
+
}
|
|
81
|
+
document.addEventListener("pointerup", this.paletteDragHandler, { once: true })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
finishPaletteDrag(event, type, key) {
|
|
85
|
+
if (this.layoutValue.length >= MAX_ELEMENTS) {
|
|
86
|
+
this.showLimitReachedMessage()
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const dimensions = DEFAULT_DIMENSIONS[type] || DEFAULT_DIMENSIONS.variable
|
|
91
|
+
let position = { xMm: 0, yMm: 0 }
|
|
92
|
+
|
|
93
|
+
if (this.hasNrbCanvasOutlet) {
|
|
94
|
+
const canvas = this.nrbCanvasOutlet
|
|
95
|
+
const target = document.elementFromPoint(event.clientX, event.clientY)
|
|
96
|
+
const droppedOnPaper = target && target.closest('[data-nrb-canvas-target="paper"]')
|
|
97
|
+
|
|
98
|
+
const raw = droppedOnPaper
|
|
99
|
+
? canvas.pointToMm(event.clientX, event.clientY)
|
|
100
|
+
: canvas.centerMm(dimensions.widthMm, dimensions.heightMm)
|
|
101
|
+
|
|
102
|
+
position = canvas.clampPosition(raw.xMm, raw.yMm, dimensions.widthMm, dimensions.heightMm)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.addElement(type, key, position, dimensions)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
addElement(type, key, position, dimensions) {
|
|
109
|
+
const element = {
|
|
110
|
+
id: crypto.randomUUID(),
|
|
111
|
+
type,
|
|
112
|
+
x_mm: position.xMm,
|
|
113
|
+
y_mm: position.yMm,
|
|
114
|
+
width_mm: dimensions.widthMm,
|
|
115
|
+
height_mm: dimensions.heightMm,
|
|
116
|
+
z_index: this.nextZIndex(),
|
|
117
|
+
visible: true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (type === "variable") {
|
|
121
|
+
element.styles = this.defaultStyles()
|
|
122
|
+
element.variable_key = key
|
|
123
|
+
element.fallback = ""
|
|
124
|
+
element.prefix = ""
|
|
125
|
+
element.suffix = ""
|
|
126
|
+
} else if (type === "text") {
|
|
127
|
+
element.styles = this.defaultStyles()
|
|
128
|
+
element.text = "New text"
|
|
129
|
+
} else {
|
|
130
|
+
element.config = this.defaultOrderLinesConfig()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.layoutValue = [...this.layoutValue, element]
|
|
134
|
+
this.selectElement(element.id)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
defaultStyles() {
|
|
138
|
+
return { font_size_pt: 10, font_weight: "normal", text_align: "left", line_height: 1.2 }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Columns come from the host's variables class, not from here. Hardcoding keys would emit a
|
|
142
|
+
// layout the server-side validator rejects for any host whose collection differs.
|
|
143
|
+
defaultOrderLinesConfig() {
|
|
144
|
+
return {
|
|
145
|
+
show_header: true,
|
|
146
|
+
row_spacing_mm: 1.0,
|
|
147
|
+
font_size_pt: 8,
|
|
148
|
+
columns: this.collectionColumnsValue.map((column) => ({
|
|
149
|
+
key: column.key,
|
|
150
|
+
label: column.label,
|
|
151
|
+
width_mm: column.width_mm,
|
|
152
|
+
align: column.align
|
|
153
|
+
}))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
nextZIndex() {
|
|
158
|
+
const max = this.layoutValue.reduce((acc, element) => Math.max(acc, element.z_index || 0), 0)
|
|
159
|
+
return Math.min(Z_INDEX_MAX, max + 1)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
showLimitReachedMessage() {
|
|
163
|
+
if (window.Toast) window.Toast.warning(`You can add up to ${MAX_ELEMENTS} elements per template.`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Selection
|
|
167
|
+
|
|
168
|
+
elementSelected(event) {
|
|
169
|
+
this.selectElement(event.detail.id)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
deselect() {
|
|
173
|
+
this.selectElement(null)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
selectElement(id) {
|
|
177
|
+
if (id === this.selectedId) return
|
|
178
|
+
|
|
179
|
+
this.selectedId = id
|
|
180
|
+
this.renderAll()
|
|
181
|
+
this.syncProperties()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
syncProperties() {
|
|
185
|
+
if (!this.hasNrbPropertiesOutlet) return
|
|
186
|
+
|
|
187
|
+
const element = this.selectedId ? this.layoutValue.find((candidate) => candidate.id === this.selectedId) : null
|
|
188
|
+
this.nrbPropertiesOutlet.render(element || null)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Geometry / property changes
|
|
192
|
+
|
|
193
|
+
elementMoved(event) {
|
|
194
|
+
this.applyPatch(event.detail.id, { x_mm: event.detail.xMm, y_mm: event.detail.yMm })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
elementResized(event) {
|
|
198
|
+
this.applyPatch(event.detail.id, {
|
|
199
|
+
x_mm: event.detail.xMm,
|
|
200
|
+
y_mm: event.detail.yMm,
|
|
201
|
+
width_mm: event.detail.widthMm,
|
|
202
|
+
height_mm: event.detail.heightMm
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
propertiesChanged(event) {
|
|
207
|
+
this.applyPatch(event.detail.id, event.detail.patch, { refreshProperties: false })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
applyPatch(id, patch, { refreshProperties = true } = {}) {
|
|
211
|
+
this.layoutValue = this.layoutValue.map((element) => (element.id === id ? this.mergePatch(element, patch) : element))
|
|
212
|
+
this.renderAll()
|
|
213
|
+
if (refreshProperties && id === this.selectedId) this.syncProperties()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
mergePatch(element, patch) {
|
|
217
|
+
const merged = { ...element, ...patch }
|
|
218
|
+
if (patch.styles) merged.styles = { ...(element.styles || {}), ...patch.styles }
|
|
219
|
+
if (patch.config) merged.config = { ...(element.config || {}), ...patch.config }
|
|
220
|
+
return merged
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
removeSelected(event) {
|
|
224
|
+
const id = (event && event.detail && event.detail.id) || this.selectedId
|
|
225
|
+
if (!id) return
|
|
226
|
+
|
|
227
|
+
this.layoutValue = this.layoutValue.filter((element) => element.id !== id)
|
|
228
|
+
if (this.selectedId === id) this.selectedId = null
|
|
229
|
+
this.renderAll()
|
|
230
|
+
this.syncProperties()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Settings panel reactivity
|
|
234
|
+
|
|
235
|
+
settingsChanged() {
|
|
236
|
+
if (!this.hasNrbCanvasOutlet) return
|
|
237
|
+
const canvas = this.nrbCanvasOutlet
|
|
238
|
+
|
|
239
|
+
if (this.hasWidthInputTarget) {
|
|
240
|
+
const value = parseFloat(this.widthInputTarget.value)
|
|
241
|
+
if (!Number.isNaN(value)) canvas.widthMmValue = value
|
|
242
|
+
}
|
|
243
|
+
if (this.hasHeightInputTarget) {
|
|
244
|
+
const value = parseFloat(this.heightInputTarget.value)
|
|
245
|
+
if (!Number.isNaN(value)) canvas.heightMmValue = value
|
|
246
|
+
}
|
|
247
|
+
if (this.hasHeightModeSelectTarget) canvas.heightModeValue = this.heightModeSelectTarget.value
|
|
248
|
+
if (this.hasMarginTopInputTarget) canvas.marginTopMmValue = parseFloat(this.marginTopInputTarget.value) || 0
|
|
249
|
+
if (this.hasMarginRightInputTarget) canvas.marginRightMmValue = parseFloat(this.marginRightInputTarget.value) || 0
|
|
250
|
+
if (this.hasMarginBottomInputTarget) canvas.marginBottomMmValue = parseFloat(this.marginBottomInputTarget.value) || 0
|
|
251
|
+
if (this.hasMarginLeftInputTarget) canvas.marginLeftMmValue = parseFloat(this.marginLeftInputTarget.value) || 0
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
paperPresetChanged() {
|
|
255
|
+
const preset = this.presetsValue[this.paperSizeSelectTarget.value]
|
|
256
|
+
if (!preset) return
|
|
257
|
+
|
|
258
|
+
if (this.hasWidthInputTarget) this.widthInputTarget.value = preset.width_mm
|
|
259
|
+
if (this.hasHeightInputTarget) this.heightInputTarget.value = preset.height_mm
|
|
260
|
+
if (this.hasOrientationSelectTarget) this.orientationSelectTarget.value = preset.orientation
|
|
261
|
+
|
|
262
|
+
this.settingsChanged()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Rendering / serialization
|
|
266
|
+
|
|
267
|
+
renderAll() {
|
|
268
|
+
if (this.hasNrbCanvasOutlet) {
|
|
269
|
+
this.nrbCanvasOutlet.renderElements(this.layoutValue, this.selectedId)
|
|
270
|
+
}
|
|
271
|
+
this.updateElementCount()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
updateElementCount() {
|
|
275
|
+
if (!this.hasElementCountTarget) return
|
|
276
|
+
const count = this.layoutValue.length
|
|
277
|
+
this.elementCountTarget.textContent = `${count} element${count === 1 ? "" : "s"}`
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Guarded because layoutValueChanged fires during initialization, before targets connect. The
|
|
281
|
+
// field is server-rendered with the current layout, so a skipped first call loses nothing.
|
|
282
|
+
serializeLayout() {
|
|
283
|
+
if (!this.hasLayoutFieldTarget) return
|
|
284
|
+
|
|
285
|
+
this.layoutFieldTarget.value = JSON.stringify(this.layoutValue)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Last line of defence at submit time, for a layout mutated in place without reassigning
|
|
289
|
+
// layoutValue. `new FormData(form)` dispatches formdata on the form, so this runs while Turbo
|
|
290
|
+
// is still assembling the body — early enough to change what is sent, which is exactly what
|
|
291
|
+
// turbo:submit-start was not.
|
|
292
|
+
writeLayoutToFormData(event) {
|
|
293
|
+
if (!this.hasLayoutFieldTarget) return
|
|
294
|
+
|
|
295
|
+
event.formData.set(this.layoutFieldTarget.name, JSON.stringify(this.layoutValue))
|
|
296
|
+
}
|
|
297
|
+
}
|