kaui 4.0.18 → 4.0.19

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b226cddd4e3508c415761943d804cab4ff71e506eca8d8ffbe592fde0cbed193
4
- data.tar.gz: 6b29cc16b8e32acc0fceca6f1444e3073e8bbe8cd29fbd59158c7edb05cf4796
3
+ metadata.gz: f667be5cb34e03c3d1b4da18ef422934d9794643606c998cf1ac84f2e6d99f60
4
+ data.tar.gz: e202ccb38314d766896d3b72235de05c2818b74b985cb8b2717ff4850ab0b4ae
5
5
  SHA512:
6
- metadata.gz: b562deca64d70f1fe3323cedd752ed20fe00202ea56e248873aeb11672a3128c45b809ff7138395dc04d301b2b35fc07b8191e0d6e653423eb18176131e3b7d2
7
- data.tar.gz: 276b11b5364329227e3be9d0b121500350ced30662505dccea557c7084eaccb91508401b73c7bf1ae80e92cec06d630ee0fab7f9bbfc1c17cbead743d2b2efa1
6
+ metadata.gz: 18c8742fd7498cab0431aa9758b7f7ebda0e225d7fba76c9dd8a1fa97bd52a8678812b3a6e0397e1d15fc8128e59f451ee455ac7ebdfe8cb13de888a86101501
7
+ data.tar.gz: ee27c185159e1b564e0a0d16322178ddf3fbbce4c78929a0dfd3747c4c198084f1269f3e7d261047b0a2589b5f08d70be3ab07f8d727dfcc969e372929f7395a
@@ -14,6 +14,7 @@ module Kaui
14
14
 
15
15
  @bundle, plans_details = lookup_bundle_and_plan_details(@subscription, @base_product_name)
16
16
  @plans = plans_details.map(&:plan)
17
+ @plan_phases = build_plan_phases_map(plans_details)
17
18
 
18
19
  return unless @plans.empty?
19
20
 
@@ -30,6 +31,7 @@ module Kaui
30
31
  _, plans_details = lookup_bundle_and_plan_details(@subscription)
31
32
  # Use a Set to deal with multiple pricelists
32
33
  @plans = Set.new.merge(plans_details.map(&:plan))
34
+ @plan_phases = build_plan_phases_map(plans_details)
33
35
  end
34
36
 
35
37
  def create
@@ -46,15 +48,7 @@ module Kaui
46
48
  @subscription.plan_name = plan_name
47
49
  requested_date = params[:type_change] == 'DATE' ? params[:requested_date].presence : nil
48
50
 
49
- # price override?
50
- override_fixed_price = begin
51
- plan_details.phases.first.prices.blank?
52
- rescue StandardError
53
- false
54
- end
55
- override_recurring_price = !override_fixed_price
56
- phase_type = @bundle.nil? ? plan_details.phases.first.type : @bundle.subscriptions.first.phase_type
57
- overrides = price_overrides(phase_type, override_fixed_price, override_recurring_price)
51
+ overrides = price_overrides(plan_details)
58
52
  @subscription.price_overrides = overrides if overrides.present?
59
53
  @subscription.quantity = params[:quantity].to_i if params[:quantity].present? && params[:quantity].to_i.positive?
60
54
 
@@ -95,11 +89,9 @@ module Kaui
95
89
 
96
90
  input = { planName: plan_name }
97
91
 
98
- # price override?
99
- current_plan = subscription.prices.select { |price| price['phaseType'] == subscription.phase_type }
100
- override_fixed_price = current_plan.last['recurringPrice'].nil?
101
- override_recurring_price = !override_fixed_price
102
- overrides = price_overrides(subscription.phase_type, override_fixed_price, override_recurring_price)
92
+ _, plans_details = lookup_bundle_and_plan_details(subscription)
93
+ plan_details = plans_details.find { |p| p.plan == plan_name }
94
+ overrides = plan_details ? price_overrides(plan_details) : nil
103
95
  input[:priceOverrides] = overrides if overrides.present?
104
96
 
105
97
  subscription.change_plan(input,
@@ -189,6 +181,7 @@ module Kaui
189
181
 
190
182
  def record_usage
191
183
  @subscription = Kaui::Subscription.find_by_id(params.require(:id), 'NONE', options_for_klient)
184
+ @unit_types = fetch_unit_types_from_subscription(@subscription)
192
185
  end
193
186
 
194
187
  def create_usage
@@ -204,16 +197,13 @@ module Kaui
204
197
  amount = Integer(amount_raw, exception: false)
205
198
  errors << 'Amount must be a positive integer' if amount.nil? || amount <= 0
206
199
  errors << 'Date/time of usage is required' if record_date.blank?
207
- parsed_date = begin
208
- record_date.blank? ? nil : Time.iso8601(record_date)
209
- rescue ArgumentError
210
- nil
211
- end
212
- errors << 'Date/time of usage must be a valid ISO 8601 timestamp' if record_date.present? && parsed_date.nil?
200
+ parsed_date = parse_usage_date(record_date) if record_date.present?
201
+ errors << 'Date/time of usage must be a valid date or datetime' if record_date.present? && parsed_date.nil?
213
202
 
214
203
  if errors.any?
215
204
  flash.now[:error] = errors.join('. ')
216
205
  @subscription = Kaui::Subscription.find_by_id(subscription_id, 'NONE', options_for_klient)
206
+ @unit_types = fetch_unit_types_from_subscription(@subscription)
217
207
  @unit_type = unit_type
218
208
  @amount = amount_raw
219
209
  @record_date = record_date
@@ -235,7 +225,7 @@ module Kaui
235
225
  usage.tracking_id = params[:tracking_id].presence
236
226
  usage.unit_usage_records = [unit_usage_record]
237
227
 
238
- usage.create(current_user.kb_username, params[:reason], params[:comment], options_for_klient)
228
+ usage.create(current_user.kb_username, nil, nil, options_for_klient)
239
229
 
240
230
  subscription = Kaui::Subscription.find_by_id(subscription_id, 'NONE', options_for_klient)
241
231
  redirect_to kaui_engine.account_bundles_path(subscription.account_id), notice: 'Usage was successfully recorded'
@@ -244,6 +234,7 @@ module Kaui
244
234
  Rails.logger.error(e.backtrace.join("\n")) if e.backtrace
245
235
  flash.now[:error] = "Error while recording usage: #{as_string(e)}"
246
236
  @subscription = Kaui::Subscription.find_by_id(subscription_id, 'NONE', options_for_klient)
237
+ @unit_types = fetch_unit_types_from_subscription(@subscription)
247
238
  @unit_type = unit_type
248
239
  @amount = amount_raw
249
240
  @record_date = record_date
@@ -357,19 +348,119 @@ module Kaui
357
348
  plans
358
349
  end
359
350
 
360
- def price_overrides(phase_type, override_fixed_price, override_recurring_price)
361
- return nil if params[:price_override].blank? || params[:price_override].to_i.negative?
351
+ def price_overrides(plan_details)
352
+ raw = params[:price_overrides]
353
+ return nil if raw.blank?
354
+
355
+ entries = raw.respond_to?(:values) ? raw.values : Array(raw)
356
+ phase_meta = (plan_details.phases || []).index_by(&:type)
357
+
358
+ overrides = entries.filter_map do |entry|
359
+ entry = entry.to_unsafe_h if entry.respond_to?(:to_unsafe_h)
360
+ entry = entry.with_indifferent_access if entry.respond_to?(:with_indifferent_access)
361
+
362
+ price_str = entry[:price].to_s.strip
363
+ phase_type = entry[:phase_type].to_s.strip
364
+
365
+ price = BigDecimal(price_str, exception: false)
366
+
367
+ next if price.nil? || phase_type.blank? || price.negative?
362
368
 
363
- price_override = params[:price_override]
364
- overrides = []
365
- override = KillBillClient::Model::PhasePriceAttributes.new
366
- override.phase_type = phase_type
367
- override.fixed_price = price_override if override_fixed_price
368
- override.recurring_price = price_override if override_recurring_price
369
+ phase = phase_meta[phase_type]
370
+ next if phase.nil?
369
371
 
370
- overrides << override
372
+ override = KillBillClient::Model::PhasePriceAttributes.new
373
+ override.phase_type = phase_type
374
+ if phase_uses_fixed_price?(phase)
375
+ override.fixed_price = price.to_s('F')
376
+ else
377
+ override.recurring_price = price.to_s('F')
378
+ end
379
+ override
380
+ end
381
+
382
+ overrides.presence
383
+ end
371
384
 
372
- overrides
385
+ def build_plan_phases_map(plans_details)
386
+ (plans_details || []).to_h do |pd|
387
+ phases = (pd.phases || []).map do |ph|
388
+ fixed = phase_uses_fixed_price?(ph)
389
+ prices = ph.prices || []
390
+ price_label = if fixed
391
+ '$0.00'
392
+ elsif prices.any?
393
+ format('$%.2f', prices.first.value.to_f)
394
+ else
395
+ ''
396
+ end
397
+ { type: ph.type, fixed: fixed, priceLabel: price_label }
398
+ end
399
+ [pd.plan, phases]
400
+ end
401
+ end
402
+
403
+ def phase_uses_fixed_price?(phase)
404
+ (phase.prices || []).empty?
405
+ end
406
+
407
+ def fetch_unit_types_from_subscription(subscription)
408
+ unit_types = []
409
+ (subscription.prices || []).each do |phase_price|
410
+ usage_prices = if phase_price.is_a?(Hash)
411
+ phase_price['usagePrices'] || phase_price[:usagePrices] || []
412
+ elsif phase_price.respond_to?(:usage_prices)
413
+ phase_price.usage_prices || []
414
+ else
415
+ []
416
+ end
417
+ (usage_prices || []).each do |usage_price|
418
+ tier_prices = if usage_price.is_a?(Hash)
419
+ usage_price['tierPrices'] || usage_price[:tierPrices] || []
420
+ elsif usage_price.respond_to?(:tier_prices)
421
+ usage_price.tier_prices || []
422
+ else
423
+ []
424
+ end
425
+ (tier_prices || []).each do |tier_price|
426
+ block_prices = if tier_price.is_a?(Hash)
427
+ tier_price['blockPrices'] || tier_price[:blockPrices] || []
428
+ elsif tier_price.respond_to?(:block_prices)
429
+ tier_price.block_prices || []
430
+ else
431
+ []
432
+ end
433
+ (block_prices || []).each do |block_price|
434
+ unit_name = if block_price.is_a?(Hash)
435
+ block_price['unitName'] || block_price[:unitName] || block_price['unit_name']
436
+ elsif block_price.respond_to?(:unit_name)
437
+ block_price.unit_name
438
+ end
439
+ unit_types << unit_name if unit_name.present?
440
+ end
441
+ end
442
+ end
443
+ end
444
+ unit_types.uniq
445
+ rescue StandardError => e
446
+ Rails.logger.warn("Failed to extract unit types from subscription #{subscription&.subscription_id}: #{e.class}: #{e.message}")
447
+ []
448
+ end
449
+
450
+ def parse_usage_date(str)
451
+ str = str.to_s.strip
452
+ return nil if str.empty?
453
+
454
+ if str.match?(/^\d{4}-\d{2}-\d{2}$/)
455
+ year, month, day = str.split('-').map(&:to_i)
456
+ Time.utc(year, month, day)
457
+ elsif str.match?(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/)
458
+ Time.iso8601("#{str}:00Z")
459
+ else
460
+ Time.iso8601(str)
461
+ end
462
+ rescue ArgumentError
463
+ nil
373
464
  end
374
465
  end
375
466
  end
@@ -61,9 +61,9 @@
61
61
  <% end %>
62
62
 
63
63
 
64
- <% if @bundles.empty? %>
64
+ <% if @bundles.empty? && @search_query.present? %>
65
65
  <div class="custom-alert custom-alert-info mt-3">
66
- <span>No bundles found<%= @search_query.present? ? " for the given search query." : "." %></span>
66
+ <span>No bundles found for the given search query.</span>
67
67
  </div>
68
68
  <% end %>
69
69
 
@@ -17,6 +17,7 @@
17
17
 
18
18
  <ul class="dropdown-menu header-menu shadow border-0 rounded-3"
19
19
  id="<%= dropdown_id %>_menu"
20
+ aria-labelledby="<%= dropdown_id %>_button"
20
21
  style="min-width: 6.25rem; display: none;">
21
22
  <% menu_items&.each do |item| %>
22
23
  <li>
@@ -31,28 +32,21 @@
31
32
 
32
33
  <%= javascript_tag do %>
33
34
  document.addEventListener('DOMContentLoaded', function () {
34
- const button = document.getElementById('<%= dropdown_id %>_button');
35
- const menu = document.getElementById('<%= dropdown_id %>_menu');
36
- const wrapper = document.getElementById('<%= dropdown_id %>_wrapper');
35
+ var button = document.getElementById('<%= dropdown_id %>_button');
36
+ var menu = document.getElementById('<%= dropdown_id %>_menu');
37
+ var wrapper = document.getElementById('<%= dropdown_id %>_wrapper');
37
38
 
38
39
  button.addEventListener('click', function (e) {
39
40
  e.stopPropagation();
40
- const isShown = menu.style.display === 'block';
41
-
42
- // Close all other open dropdowns first (including other dots menus)
43
- document.querySelectorAll('.dropdown-menu').forEach(function (m) {
44
- if (m !== menu) {
45
- m.style.display = 'none';
46
- }
47
- });
48
-
49
- // Toggle this one
41
+ var isShown = menu.style.display === 'block';
50
42
  menu.style.display = isShown ? 'none' : 'block';
43
+ button.setAttribute('aria-expanded', !isShown);
51
44
  });
52
45
 
53
46
  document.addEventListener('click', function (e) {
54
- if (!wrapper.contains(e.target)) {
47
+ if (!wrapper.contains(e.target) && menu.style.display === 'block') {
55
48
  menu.style.display = 'none';
49
+ button.setAttribute('aria-expanded', 'false');
56
50
  }
57
51
  });
58
52
  });
@@ -45,10 +45,7 @@
45
45
  type: "button",
46
46
  html_class: "kaui-button custom-hover",
47
47
  html_options: {
48
- id: "dropdownMenu1",
49
- data: {
50
- bs_toggle: "dropdown"
51
- }
48
+ id: "dropdownMenu1"
52
49
  }
53
50
  } %>
54
51
  <ul class="dropdown-menu" id="column-visibility" aria-labelledby="dropdownMenu1">
@@ -213,13 +210,15 @@
213
210
 
214
211
  <%= javascript_tag do %>
215
212
  $(document).ready(function() {
216
- // Initialize Bootstrap dropdown
217
- var dropdownElementList = [].slice.call(document.querySelectorAll('.dropdown-toggle'));
218
- var dropdownList = dropdownElementList.map(function (dropdownToggleEl) {
219
- return new bootstrap.Dropdown(dropdownToggleEl);
213
+ // Toggle Edit Columns dropdown
214
+ $('#dropdownMenu1').on('click', function(e) {
215
+ e.stopPropagation();
216
+ var $menu = $('#column-visibility');
217
+ $menu.toggleClass('show');
218
+ $(this).attr('aria-expanded', $menu.hasClass('show'));
220
219
  });
221
220
 
222
- $('.dropdown-menu').on('click', 'input[type="checkbox"], label', function(event) {
221
+ $('#column-visibility').on('click', 'input[type="checkbox"], label', function(event) {
223
222
  event.stopPropagation();
224
223
  });
225
224
 
@@ -45,10 +45,7 @@
45
45
  type: "button",
46
46
  html_class: "kaui-button custom-hover",
47
47
  html_options: {
48
- id: "dropdownMenu1",
49
- data: {
50
- bs_toggle: "dropdown"
51
- }
48
+ id: "dropdownMenu1"
52
49
  }
53
50
  } %>
54
51
  <ul class="dropdown-menu" id="column-visibility" aria-labelledby="dropdownMenu1">
@@ -213,13 +210,15 @@
213
210
 
214
211
  <%= javascript_tag do %>
215
212
  $(document).ready(function() {
216
- // Initialize Bootstrap dropdown
217
- var dropdownElementList = [].slice.call(document.querySelectorAll('.dropdown-toggle'));
218
- var dropdownList = dropdownElementList.map(function (dropdownToggleEl) {
219
- return new bootstrap.Dropdown(dropdownToggleEl);
213
+ // Toggle Edit Columns dropdown
214
+ $('#dropdownMenu1').on('click', function(e) {
215
+ e.stopPropagation();
216
+ var $menu = $('#column-visibility');
217
+ $menu.toggleClass('show');
218
+ $(this).attr('aria-expanded', $menu.hasClass('show'));
220
219
  });
221
220
 
222
- $('.dropdown-menu').on('click', 'input[type="checkbox"], label', function(event) {
221
+ $('#column-visibility').on('click', 'input[type="checkbox"], label', function(event) {
223
222
  event.stopPropagation();
224
223
  });
225
224
 
@@ -7,9 +7,49 @@
7
7
  </div>
8
8
 
9
9
  <div class="form-group d-flex pb-3 border-bottom mb-3">
10
- <%= label_tag :price_override, 'Price Override', :class => 'col-sm-3 control-label' %>
11
- <div class="col-sm-9">
12
- <%= number_field_tag :price_override, nil, min: 0, :step => :any, :class => 'form-control' %>
10
+ <div class="col-sm-3 control-label" style="padding-top: 2px;">
11
+ <div id="toggle_phase_overrides" style="cursor: pointer; user-select: none;">
12
+ <span id="toggle_phase_overrides_caret">&#9662;</span>
13
+ <strong>Phase Overrides</strong>
14
+ </div>
15
+ </div>
16
+ <div class="col-sm-9" id="phase_overrides_section">
17
+ <p class="text-muted mb-2" style="font-size: 13px;">Override prices for specific phases. All other phases will use catalog pricing.</p>
18
+ <div id="phase_overrides_headers" class="d-flex mb-1 gap-2" style="display: none;">
19
+ <div style="flex: 1; padding-left: 2px;"><small><strong>Phase</strong></small></div>
20
+ <div style="flex: 1; padding-left: 2px;">
21
+ <small><strong>Override Price</strong></small>
22
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="vertical-align: middle; margin-left: 3px;" title="Leave blank to use catalog pricing">
23
+ <circle cx="8" cy="8" r="7" stroke="#A4A7AE" stroke-width="1.25"/>
24
+ <path d="M8 7v5M8 5.5v-.5" stroke="#A4A7AE" stroke-width="1.5" stroke-linecap="round"/>
25
+ </svg>
26
+ </div>
27
+ <div style="width: 40px;"></div>
28
+ </div>
29
+ <div id="phase_overrides_rows"></div>
30
+ <button type="button" id="add_phase_override" class="btn w-100 mt-2 d-flex align-items-center justify-content-center gap-1"
31
+ style="border: 1px dashed #d0d5dd; background: white; color: #2563eb; font-size: 14px;">
32
+ <span style="font-size: 16px; line-height: 1;">+</span> Add another phase
33
+ </button>
34
+ <script type="application/json" id="plan_phases_data"><%= raw json_escape((@plan_phases || {}).to_json) %></script>
35
+ <style>.phase-override-remove:hover svg path { stroke: #D92D20; }</style>
36
+ <template id="phase_override_row_template">
37
+ <div class="phase-override-row d-flex align-items-center mb-2 gap-2">
38
+ <select name="price_overrides[][phase_type]" class="form-control phase-override-type" style="flex: 1;"></select>
39
+ <div class="input-group phase-override-price-group" style="flex: 1;">
40
+ <span class="input-group-text">$</span>
41
+ <input type="number" name="price_overrides[][price]" step="any" min="0" placeholder="0.00" class="form-control phase-override-price">
42
+ </div>
43
+ <button type="button" class="btn btn-outline-secondary phase-override-remove" style="padding: 6px 10px; flex-shrink: 0;" aria-label="Remove">
44
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
45
+ <path d="M13 3.66699L12.4093 13.4143C12.3666 14.1181 11.7834 14.667 11.0783 14.667H4.92164C4.21659 14.667 3.6334 14.1181 3.59075 13.4143L3 3.66699" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
46
+ <path d="M2 3.66634H5.33333M5.33333 3.66634L6.16017 1.73706C6.26522 1.49194 6.50625 1.33301 6.77293 1.33301H9.22707C9.49373 1.33301 9.7348 1.49194 9.8398 1.73706L10.6667 3.66634M5.33333 3.66634H10.6667M14 3.66634H10.6667" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
47
+ <path d="M6.33325 11V7" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
48
+ <path d="M9.66675 11V7" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
49
+ </svg>
50
+ </button>
51
+ </div>
52
+ </template>
13
53
  </div>
14
54
  </div>
15
55
  <div class="form-group d-flex pb-3" id="radio_group">
@@ -106,5 +146,99 @@ $(document).ready(function() {
106
146
  $( "input[type=radio]").on("click", function() {
107
147
  onTypeChange();
108
148
  });
149
+ initPhaseOverrides();
109
150
  });
151
+
152
+ function initPhaseOverrides() {
153
+ var planPhases = {};
154
+ try { planPhases = JSON.parse($('#plan_phases_data').text() || '{}'); } catch (e) { planPhases = {}; }
155
+
156
+ var $section = $('#phase_overrides_section');
157
+ var $rows = $('#phase_overrides_rows');
158
+ var $addBtn = $('#add_phase_override');
159
+ var $headers = $('#phase_overrides_headers');
160
+ var $template = $('#phase_override_row_template');
161
+ var $planSelect = $('select[name="plan_name"]');
162
+
163
+ function currentPhases() {
164
+ return planPhases[$planSelect.val()] || [];
165
+ }
166
+
167
+ function phaseLabel(p) {
168
+ var title = p.type.charAt(0) + p.type.slice(1).toLowerCase();
169
+ return p.priceLabel ? title + ' (' + p.priceLabel + ')' : title;
170
+ }
171
+
172
+ function selectedTypes($exceptRow) {
173
+ return $rows.find('.phase-override-row').filter(function() {
174
+ return !$exceptRow || this !== $exceptRow[0];
175
+ }).map(function() { return $(this).find('.phase-override-type').val(); }).get();
176
+ }
177
+
178
+ function refreshAddBtn() {
179
+ var phases = currentPhases();
180
+ var used = selectedTypes();
181
+ var hasMore = phases.length > 0 && used.length < phases.length;
182
+ $addBtn.toggle(hasMore);
183
+ $headers.css('display', $rows.find('.phase-override-row').length > 0 ? 'flex' : 'none');
184
+ }
185
+
186
+ function rebuildRowOptions() {
187
+ var phases = currentPhases();
188
+ $rows.find('.phase-override-row').each(function() {
189
+ var $row = $(this);
190
+ var $select = $row.find('.phase-override-type');
191
+ var current = $select.val();
192
+ var used = selectedTypes($row);
193
+ $select.empty();
194
+ phases.forEach(function(p) {
195
+ if (used.indexOf(p.type) === -1) {
196
+ $select.append($('<option>', { value: p.type, text: phaseLabel(p) }));
197
+ }
198
+ });
199
+ var currentStillValid = phases.some(function(p) { return p.type === current; }) && used.indexOf(current) === -1;
200
+ if (currentStillValid) $select.val(current);
201
+ });
202
+ refreshAddBtn();
203
+ }
204
+
205
+ function addRow(preferredType) {
206
+ var phases = currentPhases();
207
+ if (phases.length === 0) return;
208
+ var used = selectedTypes();
209
+ var next = preferredType && phases.some(function(p) { return p.type === preferredType; }) && used.indexOf(preferredType) === -1
210
+ ? preferredType
211
+ : (phases.find(function(p) { return used.indexOf(p.type) === -1; }) || {}).type;
212
+ if (!next) return;
213
+
214
+ var $row = $($template.html());
215
+ $rows.append($row);
216
+ rebuildRowOptions();
217
+ $row.find('.phase-override-type').val(next);
218
+ rebuildRowOptions();
219
+ }
220
+
221
+ function resetRows() {
222
+ $rows.empty();
223
+ var phases = currentPhases();
224
+ if (phases.length > 0) addRow(phases[0].type);
225
+ refreshAddBtn();
226
+ }
227
+
228
+ $('#toggle_phase_overrides').on('click', function() {
229
+ $section.toggle();
230
+ $('#toggle_phase_overrides_caret').html($section.is(':visible') ? '&#9662;' : '&#9656;');
231
+ });
232
+
233
+ $addBtn.on('click', function() { addRow(); });
234
+
235
+ $rows.on('change', '.phase-override-type', rebuildRowOptions);
236
+ $rows.on('click', '.phase-override-remove', function() {
237
+ $(this).closest('.phase-override-row').remove();
238
+ refreshAddBtn();
239
+ });
240
+
241
+ $planSelect.on('change', resetRows);
242
+ resetRows();
243
+ }
110
244
  <% end %>
@@ -35,9 +35,49 @@
35
35
  </div>
36
36
  </div>
37
37
  <div class="form-group d-flex pb-3">
38
- <%= label_tag :price_override, 'Price Override', :class => 'col-sm-3 control-label' %>
39
- <div class="col-sm-9">
40
- <%= number_field_tag :price_override, nil, :step => :any, :min => 0, :class => 'form-control' %>
38
+ <div class="col-sm-3 control-label" style="padding-top: 2px;">
39
+ <div id="toggle_phase_overrides" style="cursor: pointer; user-select: none;">
40
+ <span id="toggle_phase_overrides_caret">&#9662;</span>
41
+ <strong>Phase Overrides</strong>
42
+ </div>
43
+ </div>
44
+ <div class="col-sm-9" id="phase_overrides_section">
45
+ <p class="text-muted mb-2" style="font-size: 13px;">Override prices for specific phases. All other phases will use catalog pricing.</p>
46
+ <div id="phase_overrides_headers" class="d-flex mb-1 gap-2" style="display: none;">
47
+ <div style="flex: 1; padding-left: 2px;"><small><strong>Phase</strong></small></div>
48
+ <div style="flex: 1; padding-left: 2px;">
49
+ <small><strong>Override Price</strong></small>
50
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="vertical-align: middle; margin-left: 3px;" title="Leave blank to use catalog pricing">
51
+ <circle cx="8" cy="8" r="7" stroke="#A4A7AE" stroke-width="1.25"/>
52
+ <path d="M8 7v5M8 5.5v-.5" stroke="#A4A7AE" stroke-width="1.5" stroke-linecap="round"/>
53
+ </svg>
54
+ </div>
55
+ <div style="width: 40px;"></div>
56
+ </div>
57
+ <div id="phase_overrides_rows"></div>
58
+ <button type="button" id="add_phase_override" class="btn w-100 mt-2 d-flex align-items-center justify-content-center gap-1"
59
+ style="border: 1px dashed #d0d5dd; background: white; color: #2563eb; font-size: 14px;">
60
+ <span style="font-size: 16px; line-height: 1;">+</span> Add another phase
61
+ </button>
62
+ <script type="application/json" id="plan_phases_data"><%= raw json_escape((@plan_phases || {}).to_json) %></script>
63
+ <style>.phase-override-remove:hover svg path { stroke: #D92D20; }</style>
64
+ <template id="phase_override_row_template">
65
+ <div class="phase-override-row d-flex align-items-center mb-2 gap-2">
66
+ <select name="price_overrides[][phase_type]" class="form-control phase-override-type" style="flex: 1;"></select>
67
+ <div class="input-group phase-override-price-group" style="flex: 1;">
68
+ <span class="input-group-text">$</span>
69
+ <input type="number" name="price_overrides[][price]" step="any" min="0" placeholder="0.00" class="form-control phase-override-price">
70
+ </div>
71
+ <button type="button" class="btn btn-outline-secondary phase-override-remove" style="padding: 6px 10px; flex-shrink: 0;" aria-label="Remove">
72
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
73
+ <path d="M13 3.66699L12.4093 13.4143C12.3666 14.1181 11.7834 14.667 11.0783 14.667H4.92164C4.21659 14.667 3.6334 14.1181 3.59075 13.4143L3 3.66699" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
74
+ <path d="M2 3.66634H5.33333M5.33333 3.66634L6.16017 1.73706C6.26522 1.49194 6.50625 1.33301 6.77293 1.33301H9.22707C9.49373 1.33301 9.7348 1.49194 9.8398 1.73706L10.6667 3.66634M5.33333 3.66634H10.6667M14 3.66634H10.6667" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
75
+ <path d="M6.33325 11V7" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
76
+ <path d="M9.66675 11V7" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
77
+ </svg>
78
+ </button>
79
+ </div>
80
+ </template>
41
81
  </div>
42
82
  </div>
43
83
  <div class="form-group d-flex pb-3 border-bottom mb-3">
@@ -123,5 +163,99 @@ $(document).ready(function() {
123
163
  $( "input[type=radio]").on("click", function() {
124
164
  onTypeChange();
125
165
  });
166
+ initPhaseOverrides();
126
167
  });
168
+
169
+ function initPhaseOverrides() {
170
+ var planPhases = {};
171
+ try { planPhases = JSON.parse($('#plan_phases_data').text() || '{}'); } catch (e) { planPhases = {}; }
172
+
173
+ var $section = $('#phase_overrides_section');
174
+ var $rows = $('#phase_overrides_rows');
175
+ var $addBtn = $('#add_phase_override');
176
+ var $headers = $('#phase_overrides_headers');
177
+ var $template = $('#phase_override_row_template');
178
+ var $planSelect = $('select[name="plan_name"]');
179
+
180
+ function currentPhases() {
181
+ return planPhases[$planSelect.val()] || [];
182
+ }
183
+
184
+ function phaseLabel(p) {
185
+ var title = p.type.charAt(0) + p.type.slice(1).toLowerCase();
186
+ return p.priceLabel ? title + ' (' + p.priceLabel + ')' : title;
187
+ }
188
+
189
+ function selectedTypes($exceptRow) {
190
+ return $rows.find('.phase-override-row').filter(function() {
191
+ return !$exceptRow || this !== $exceptRow[0];
192
+ }).map(function() { return $(this).find('.phase-override-type').val(); }).get();
193
+ }
194
+
195
+ function refreshAddBtn() {
196
+ var phases = currentPhases();
197
+ var used = selectedTypes();
198
+ var hasMore = phases.length > 0 && used.length < phases.length;
199
+ $addBtn.toggle(hasMore);
200
+ $headers.css('display', $rows.find('.phase-override-row').length > 0 ? 'flex' : 'none');
201
+ }
202
+
203
+ function rebuildRowOptions() {
204
+ var phases = currentPhases();
205
+ $rows.find('.phase-override-row').each(function() {
206
+ var $row = $(this);
207
+ var $select = $row.find('.phase-override-type');
208
+ var current = $select.val();
209
+ var used = selectedTypes($row);
210
+ $select.empty();
211
+ phases.forEach(function(p) {
212
+ if (used.indexOf(p.type) === -1) {
213
+ $select.append($('<option>', { value: p.type, text: phaseLabel(p) }));
214
+ }
215
+ });
216
+ var currentStillValid = phases.some(function(p) { return p.type === current; }) && used.indexOf(current) === -1;
217
+ if (currentStillValid) $select.val(current);
218
+ });
219
+ refreshAddBtn();
220
+ }
221
+
222
+ function addRow(preferredType) {
223
+ var phases = currentPhases();
224
+ if (phases.length === 0) return;
225
+ var used = selectedTypes();
226
+ var next = preferredType && phases.some(function(p) { return p.type === preferredType; }) && used.indexOf(preferredType) === -1
227
+ ? preferredType
228
+ : (phases.find(function(p) { return used.indexOf(p.type) === -1; }) || {}).type;
229
+ if (!next) return;
230
+
231
+ var $row = $($template.html());
232
+ $rows.append($row);
233
+ rebuildRowOptions();
234
+ $row.find('.phase-override-type').val(next);
235
+ rebuildRowOptions();
236
+ }
237
+
238
+ function resetRows() {
239
+ $rows.empty();
240
+ var phases = currentPhases();
241
+ if (phases.length > 0) addRow(phases[0].type);
242
+ refreshAddBtn();
243
+ }
244
+
245
+ $('#toggle_phase_overrides').on('click', function() {
246
+ $section.toggle();
247
+ $('#toggle_phase_overrides_caret').html($section.is(':visible') ? '&#9662;' : '&#9656;');
248
+ });
249
+
250
+ $addBtn.on('click', function() { addRow(); });
251
+
252
+ $rows.on('change', '.phase-override-type', rebuildRowOptions);
253
+ $rows.on('click', '.phase-override-remove', function() {
254
+ $(this).closest('.phase-override-row').remove();
255
+ refreshAddBtn();
256
+ });
257
+
258
+ $planSelect.on('change', resetRows);
259
+ resetRows();
260
+ }
127
261
  <% end %>
@@ -24,7 +24,13 @@
24
24
  <div class="form-group d-flex pb-3">
25
25
  <%= label_tag :unit_type, 'Unit Type', class: 'col-sm-3 control-label' %>
26
26
  <div class="col-sm-9">
27
- <%= text_field_tag :unit_type, @unit_type, required: true, maxlength: 255, class: 'form-control', placeholder: 'e.g. api-calls' %>
27
+ <% if @unit_types.length == 1 %>
28
+ <%= text_field_tag :unit_type, @unit_types.first, readonly: true, required: true, class: 'form-control bg-light' %>
29
+ <% elsif @unit_types.length > 1 %>
30
+ <%= select_tag :unit_type, options_for_select(@unit_types.map { |u| [u, u] }, @unit_type.presence || @unit_types.first), required: true, class: 'form-select' %>
31
+ <% else %>
32
+ <%= text_field_tag :unit_type, @unit_type, required: true, maxlength: 255, class: 'form-control', placeholder: 'e.g. api-calls' %>
33
+ <% end %>
28
34
  </div>
29
35
  </div>
30
36
 
@@ -38,12 +44,11 @@
38
44
  <div class="form-group d-flex pb-3">
39
45
  <%= label_tag :record_date, 'Date/Time of Usage', class: 'col-sm-3 control-label' %>
40
46
  <div class="col-sm-9">
41
- <%= text_field_tag :record_date,
42
- @record_date.presence || Time.now.utc.iso8601,
43
- required: true,
44
- class: 'form-control',
45
- placeholder: 'YYYY-MM-DDTHH:MM:SSZ (ISO 8601)' %>
46
- <small class="form-text text-muted">ISO 8601 format, e.g. <code>2026-06-13T15:30:00Z</code></small>
47
+ <%= datetime_field_tag :record_date,
48
+ @record_date.presence || Time.now.utc.strftime('%Y-%m-%dT%H:%M'),
49
+ required: true,
50
+ class: 'form-control' %>
51
+ <small class="form-text text-muted">Date and time treated as UTC</small>
47
52
  </div>
48
53
  </div>
49
54
 
@@ -54,20 +59,6 @@
54
59
  </div>
55
60
  </div>
56
61
 
57
- <div class="form-group d-flex pb-3 border-bottom mb-3">
58
- <%= label_tag :reason, 'Reason', class: 'col-sm-3 control-label' %>
59
- <div class="col-sm-9">
60
- <%= text_field_tag :reason, params[:reason], class: 'form-control' %>
61
- </div>
62
- </div>
63
-
64
- <div class="form-group d-flex pb-3 border-bottom mb-3">
65
- <%= label_tag :comment, 'Comment', class: 'col-sm-3 control-label' %>
66
- <div class="col-sm-9">
67
- <%= text_field_tag :comment, params[:comment], class: 'form-control' %>
68
- </div>
69
- </div>
70
-
71
62
  <div class="form-group d-flex justify-content-end pb-3">
72
63
  <%= render "kaui/components/button/button", {
73
64
  label: 'Close',
@@ -98,7 +89,8 @@
98
89
  form.addEventListener('submit', function (e) {
99
90
  var errors = [];
100
91
 
101
- var unitType = (document.getElementById('unit_type').value || '').trim();
92
+ var unitTypeEl = document.getElementById('unit_type');
93
+ var unitType = unitTypeEl ? (unitTypeEl.value || '').trim() : '';
102
94
  if (unitType.length === 0) {
103
95
  errors.push('Unit type is required.');
104
96
  }
@@ -109,14 +101,6 @@
109
101
  errors.push('Amount must be a positive integer.');
110
102
  }
111
103
 
112
- var dateStr = (document.getElementById('record_date').value || '').trim();
113
- // Permissive ISO 8601 check
114
- var isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/;
115
- var parsed = Date.parse(dateStr);
116
- if (dateStr.length === 0 || isNaN(parsed) || !isoRegex.test(dateStr)) {
117
- errors.push('Date/time of usage must be a valid ISO 8601 timestamp (e.g. 2026-06-13T15:30:00Z).');
118
- }
119
-
120
104
  if (errors.length > 0) {
121
105
  e.preventDefault();
122
106
  alert(errors.join('\n'));
data/lib/kaui/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kaui
4
- VERSION = '4.0.18'
4
+ VERSION = '4.0.19'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kaui
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.18
4
+ version: 4.0.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kill Bill core team
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-18 00:00:00.000000000 Z
11
+ date: 2026-07-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack