kaui 4.0.18 → 4.0.20

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: ef67e5aac2cf29cee71a7a52b5b87452afc410ed711d1d55c08225dc39b1d044
4
+ data.tar.gz: 6232e9397218afea690a6d45422623b16fb346489a380eaaa6fdba3ed6e98930
5
5
  SHA512:
6
- metadata.gz: b562deca64d70f1fe3323cedd752ed20fe00202ea56e248873aeb11672a3128c45b809ff7138395dc04d301b2b35fc07b8191e0d6e653423eb18176131e3b7d2
7
- data.tar.gz: 276b11b5364329227e3be9d0b121500350ced30662505dccea557c7084eaccb91508401b73c7bf1ae80e92cec06d630ee0fab7f9bbfc1c17cbead743d2b2efa1
6
+ metadata.gz: d2cd48b2725369b2a466e49cabbf656ce64b6bea4cdcb272f97fb7d34c909bdb56a6876c13af14328a1dcdd0d341e9d04757318d9504aeece87d55ad2ba8a6fa
7
+ data.tar.gz: 8abadbb98b2d800acb54b5dd7fd9bdcf20a8a49186c1b3b9b2357eb3a717ff6a79a45094aebcfaa252a11a8ca562ac5ab2812080c6abb3774ea711b57d98099b
@@ -47,6 +47,12 @@ module Kaui
47
47
  @overdue_xml = nil
48
48
  end
49
49
 
50
+ fetch_invoice_template = promise do
51
+ Kaui::AdminTenant.get_invoice_template(false, options)
52
+ rescue StandardError
53
+ @invoice_template = nil
54
+ end
55
+
50
56
  fetch_tenant_plugin_config = promise { Kaui::AdminTenant.get_tenant_plugin_config(options) }
51
57
 
52
58
  @catalog_versions = []
@@ -64,6 +70,12 @@ module Kaui
64
70
  @overdue = flash[:overdue_deleted] ? nil : wait(fetch_overdue)
65
71
  @overdue_xml = flash[:overdue_deleted] ? nil : wait(fetch_overdue_xml)
66
72
  @overdue_config_exists = @overdue_xml.present? || (@overdue&.overdue_states.present? && !@overdue.has_states)
73
+ @invoice_template = begin
74
+ wait(fetch_invoice_template)
75
+ rescue StandardError
76
+ nil
77
+ end
78
+
67
79
  @tenant_plugin_config = begin
68
80
  wait(fetch_tenant_plugin_config)
69
81
  rescue StandardError
@@ -367,6 +379,22 @@ module Kaui
367
379
  redirect_to admin_tenant_path(current_tenant.id, active_tab: 'OverdueShow'), notice: I18n.t('flashes.notices.overdue_deleted_successfully')
368
380
  end
369
381
 
382
+ def invoice_template
383
+ current_tenant = safely_find_tenant_by_id(params[:id])
384
+
385
+ options = tenant_options_for_client
386
+ options[:api_key] = current_tenant.api_key
387
+ options[:api_secret] = current_tenant.api_secret
388
+
389
+ template = Kaui::AdminTenant.get_invoice_template(false, options)
390
+
391
+ if template.present?
392
+ render body: template, content_type: 'text/html'
393
+ else
394
+ render plain: 'No invoice template found', status: :not_found
395
+ end
396
+ end
397
+
370
398
  def upload_invoice_template
371
399
  current_tenant = safely_find_tenant_by_id(params[:id])
372
400
 
@@ -380,7 +408,7 @@ module Kaui
380
408
 
381
409
  Kaui::AdminTenant.upload_invoice_template(invoice_template, is_manual_pay, true, options[:username], nil, comment, options)
382
410
 
383
- redirect_to admin_tenant_path(current_tenant.id), notice: I18n.t('flashes.notices.invoice_template_uploaded_successfully')
411
+ redirect_to admin_tenant_path(current_tenant.id, active_tab: 'InvoiceTemplate'), notice: I18n.t('flashes.notices.invoice_template_uploaded_successfully')
384
412
  end
385
413
 
386
414
  def upload_invoice_translation
@@ -394,9 +422,14 @@ module Kaui
394
422
  uploaded_invoice_translation = params.require(:invoice_translation)
395
423
  invoice_translation = uploaded_invoice_translation.read
396
424
 
425
+ if locale.blank?
426
+ flash[:error] = I18n.t('errors.messages.locale_required')
427
+ redirect_to admin_tenant_path(current_tenant.id, active_tab: 'InvoiceTranslation') and return
428
+ end
429
+
397
430
  Kaui::AdminTenant.upload_invoice_translation(invoice_translation, locale, true, options[:username], nil, comment, options)
398
431
 
399
- redirect_to admin_tenant_path(current_tenant.id), notice: I18n.t('flashes.notices.invoice_translation_uploaded_successfully')
432
+ redirect_to admin_tenant_path(current_tenant.id, active_tab: 'InvoiceTranslation'), notice: I18n.t('flashes.notices.invoice_translation_uploaded_successfully')
400
433
  end
401
434
 
402
435
  def upload_catalog_translation
@@ -410,9 +443,14 @@ module Kaui
410
443
  uploaded_catalog_translation = params.require(:catalog_translation)
411
444
  catalog_translation = uploaded_catalog_translation.read
412
445
 
446
+ if locale.blank?
447
+ flash[:error] = I18n.t('errors.messages.locale_required')
448
+ redirect_to admin_tenant_path(current_tenant.id, active_tab: 'CatalogTranslation') and return
449
+ end
450
+
413
451
  Kaui::AdminTenant.upload_catalog_translation(catalog_translation, locale, true, options[:username], nil, comment, options)
414
452
 
415
- redirect_to admin_tenant_path(current_tenant.id), notice: I18n.t('flashes.notices.catalog_translation_uploaded_successfully')
453
+ redirect_to admin_tenant_path(current_tenant.id, active_tab: 'CatalogTranslation'), notice: I18n.t('flashes.notices.catalog_translation_uploaded_successfully')
416
454
  end
417
455
 
418
456
  def upload_plugin_config
@@ -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,126 @@ 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.respond_to?(:phases) ? (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?
368
+
369
+ phase = phase_meta[phase_type]
370
+ next if phase.nil?
371
+
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
384
+
385
+ def build_plan_phases_map(plans_details)
386
+ (plans_details || []).to_h do |pd|
387
+ # PlanDetail objects returned by available_base_plans/available_addons only expose the
388
+ # final phase (product/plan/priceList/finalPhaseBillingPeriod/finalPhaseRecurringPrice)
389
+ # and have no phases method, unlike full catalog Plan objects. Skip phase overrides for those.
390
+ phases = if pd.respond_to?(:phases)
391
+ (pd.phases || []).map do |ph|
392
+ fixed = phase_uses_fixed_price?(ph)
393
+ prices = ph.prices || []
394
+ price_label = if fixed
395
+ '$0.00'
396
+ elsif prices.any?
397
+ format('$%.2f', prices.first.value.to_f)
398
+ else
399
+ ''
400
+ end
401
+ { type: ph.type, fixed: fixed, priceLabel: price_label }
402
+ end
403
+ else
404
+ []
405
+ end
406
+ [pd.plan, phases]
407
+ end
408
+ end
362
409
 
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
410
+ def phase_uses_fixed_price?(phase)
411
+ (phase.prices || []).empty?
412
+ end
369
413
 
370
- overrides << override
414
+ def fetch_unit_types_from_subscription(subscription)
415
+ unit_types = []
416
+ (subscription.prices || []).each do |phase_price|
417
+ usage_prices = if phase_price.is_a?(Hash)
418
+ phase_price['usagePrices'] || phase_price[:usagePrices] || []
419
+ elsif phase_price.respond_to?(:usage_prices)
420
+ phase_price.usage_prices || []
421
+ else
422
+ []
423
+ end
424
+ (usage_prices || []).each do |usage_price|
425
+ tier_prices = if usage_price.is_a?(Hash)
426
+ usage_price['tierPrices'] || usage_price[:tierPrices] || []
427
+ elsif usage_price.respond_to?(:tier_prices)
428
+ usage_price.tier_prices || []
429
+ else
430
+ []
431
+ end
432
+ (tier_prices || []).each do |tier_price|
433
+ block_prices = if tier_price.is_a?(Hash)
434
+ tier_price['blockPrices'] || tier_price[:blockPrices] || []
435
+ elsif tier_price.respond_to?(:block_prices)
436
+ tier_price.block_prices || []
437
+ else
438
+ []
439
+ end
440
+ (block_prices || []).each do |block_price|
441
+ unit_name = if block_price.is_a?(Hash)
442
+ block_price['unitName'] || block_price[:unitName] || block_price['unit_name']
443
+ elsif block_price.respond_to?(:unit_name)
444
+ block_price.unit_name
445
+ end
446
+ unit_types << unit_name if unit_name.present?
447
+ end
448
+ end
449
+ end
450
+ end
451
+ unit_types.uniq
452
+ rescue StandardError => e
453
+ Rails.logger.warn("Failed to extract unit types from subscription #{subscription&.subscription_id}: #{e.class}: #{e.message}")
454
+ []
455
+ end
371
456
 
372
- overrides
457
+ def parse_usage_date(str)
458
+ str = str.to_s.strip
459
+ return nil if str.empty?
460
+
461
+ if str.match?(/^\d{4}-\d{2}-\d{2}$/)
462
+ year, month, day = str.split('-').map(&:to_i)
463
+ Time.utc(year, month, day)
464
+ elsif str.match?(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/)
465
+ Time.iso8601("#{str}:00Z")
466
+ else
467
+ Time.iso8601(str)
468
+ end
469
+ rescue ArgumentError
470
+ nil
373
471
  end
374
472
  end
375
473
  end
@@ -15,6 +15,10 @@ module Kaui
15
15
  KillBillClient::Model::Invoice.upload_invoice_template(invoice_template, is_manual_pay, delete_if_exists, user, reason, comment, options)
16
16
  end
17
17
 
18
+ def get_invoice_template(is_manual_pay, options = {})
19
+ KillBillClient::Model::Invoice.get_invoice_template(is_manual_pay, nil, options)
20
+ end
21
+
18
22
  def upload_invoice_translation(invoice_translation, locale, delete_if_exists, user = nil, reason = nil, comment = nil, options = {})
19
23
  KillBillClient::Model::Invoice.upload_invoice_translation(invoice_translation, locale, delete_if_exists, user, reason, comment, options)
20
24
  end
@@ -21,7 +21,7 @@
21
21
  <div class="form-group d-flex pb-3 border-bottom mb-3">
22
22
  <%= label_tag :translation_locale, 'Locale', :class => 'col-sm-2 control-label' %>
23
23
  <div class="col-sm-10">
24
- <%= text_field_tag :translation_locale, nil, :class => 'form-control' %>
24
+ <%= text_field_tag :translation_locale, nil, :class => 'form-control', :required => true, :placeholder => 'e.g. en_US' %>
25
25
  </div>
26
26
  </div>
27
27
  <div class="d-flex justify-content-end">
@@ -1,5 +1,28 @@
1
1
  <% if can? :config_upload, Kaui::AdminTenant %>
2
2
  <div class="tab-pane fade invoice-template form-invoice-template" id="InvoiceTemplate">
3
+ <% if @invoice_template.present? %>
4
+ <div class="invoice-template-display mb-4">
5
+ <h5>Current Invoice Template</h5>
6
+ <div class="card">
7
+ <div class="card-body p-0">
8
+ <iframe src="<%= admin_tenant_invoice_template_path(@tenant.id) %>" style="width: 100%; height: 500px; border: none;" title="Invoice Template Preview"></iframe>
9
+ </div>
10
+ </div>
11
+ <div class="mt-2">
12
+ <%= render "kaui/components/button/button", {
13
+ label: 'View Source',
14
+ variant: "outline-secondary d-inline-flex align-items-center gap-1",
15
+ type: "button",
16
+ html_class: "kaui-dropdown custom-hover",
17
+ html_options: {
18
+ onclick: '$(this).toggleClass("active"); $(this).next("pre").toggle(); return false;'
19
+ }
20
+ } %>
21
+ <pre class="mb-0 mt-2 p-2 bg-light" style="display: none; max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; font-family: monospace; font-size: 12px;"><%= html_escape(@invoice_template) %></pre>
22
+ </div>
23
+ </div>
24
+ <% end %>
25
+
3
26
  <%= form_tag({:action => :upload_invoice_template}, :method => 'post', :multipart => true, :class => 'form-horizontal') do %>
4
27
  <%= hidden_field_tag(:id, @tenant.id) %>
5
28
 
@@ -19,8 +42,6 @@
19
42
  %>
20
43
  </div>
21
44
 
22
-
23
-
24
45
  <div class="form-group pb-3 border-bottom mb-3">
25
46
  <div class="col-sm-10">
26
47
  <div class="checkbox">
@@ -21,7 +21,7 @@
21
21
  <div class="form-group d-flex pb-3 border-bottom mb-3">
22
22
  <%= label_tag :translation_locale, 'Locale', :class => 'col-sm-2 control-label' %>
23
23
  <div class="col-sm-10">
24
- <%= text_field_tag :translation_locale, nil, :class => 'form-control' %>
24
+ <%= text_field_tag :translation_locale, nil, :class => 'form-control', :required => true, :placeholder => 'e.g. en_US' %>
25
25
  </div>
26
26
  </div>
27
27
  <div class="d-flex justify-content-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 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,109 @@ $(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
+ var phases = planPhases[$planSelect.val()] || [];
165
+ var seen = {};
166
+ return phases.filter(function(p) {
167
+ if (!p || !p.type || seen[p.type]) return false;
168
+ seen[p.type] = true;
169
+ return true;
170
+ });
171
+ }
172
+
173
+ function phaseLabel(p) {
174
+ var title = p.type.charAt(0) + p.type.slice(1).toLowerCase();
175
+ return p.priceLabel ? title + ' (' + p.priceLabel + ')' : title;
176
+ }
177
+
178
+ function selectedTypes($exceptRow) {
179
+ return $rows.find('.phase-override-row').filter(function() {
180
+ return !$exceptRow || this !== $exceptRow[0];
181
+ }).map(function() { return $(this).find('.phase-override-type').val(); }).get();
182
+ }
183
+
184
+ function refreshAddBtn() {
185
+ var phases = currentPhases();
186
+ var used = selectedTypes().filter(function(type) { return type; });
187
+ var hasMore = phases.length > 0 && used.length < phases.length;
188
+ if (hasMore) {
189
+ $addBtn.css('display', 'flex');
190
+ } else {
191
+ $addBtn.hide();
192
+ }
193
+ $headers.css('display', $rows.find('.phase-override-row').length > 0 ? 'flex' : 'none');
194
+ }
195
+
196
+ function rebuildRowOptions() {
197
+ var phases = currentPhases();
198
+ $rows.find('.phase-override-row').each(function() {
199
+ var $row = $(this);
200
+ var $select = $row.find('.phase-override-type');
201
+ var current = $select.val();
202
+ var used = selectedTypes($row);
203
+ $select.empty();
204
+ phases.forEach(function(p) {
205
+ if (used.indexOf(p.type) === -1) {
206
+ $select.append($('<option>', { value: p.type, text: phaseLabel(p) }));
207
+ }
208
+ });
209
+ var currentStillValid = phases.some(function(p) { return p.type === current; }) && used.indexOf(current) === -1;
210
+ if (currentStillValid) $select.val(current);
211
+ });
212
+ refreshAddBtn();
213
+ }
214
+
215
+ function addRow(preferredType) {
216
+ var phases = currentPhases();
217
+ if (phases.length === 0) return;
218
+ var used = selectedTypes().filter(function(type) { return type; });
219
+ var next = preferredType && phases.some(function(p) { return p.type === preferredType; }) && used.indexOf(preferredType) === -1
220
+ ? preferredType
221
+ : (phases.find(function(p) { return used.indexOf(p.type) === -1; }) || {}).type;
222
+ if (!next) return;
223
+
224
+ var $row = $($template.html());
225
+ $rows.append($row);
226
+ rebuildRowOptions();
227
+ $row.find('.phase-override-type').val(next);
228
+ rebuildRowOptions();
229
+ }
230
+
231
+ function resetRows() {
232
+ $rows.empty();
233
+ var phases = currentPhases();
234
+ if (phases.length > 0) addRow(phases[0].type);
235
+ refreshAddBtn();
236
+ }
237
+
238
+ $('#toggle_phase_overrides').on('click', function() {
239
+ $section.toggle();
240
+ $('#toggle_phase_overrides_caret').html($section.is(':visible') ? '&#9662;' : '&#9656;');
241
+ });
242
+
243
+ $addBtn.on('click', function() { addRow(); });
244
+
245
+ $rows.on('change', '.phase-override-type', rebuildRowOptions);
246
+ $rows.on('click', '.phase-override-remove', function() {
247
+ $(this).closest('.phase-override-row').remove();
248
+ refreshAddBtn();
249
+ });
250
+
251
+ $planSelect.on('change', resetRows);
252
+ resetRows();
253
+ }
110
254
  <% 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">&#9656;</span>
41
+ <strong>Price Override</strong>
42
+ </div>
43
+ </div>
44
+ <div class="col-sm-9" id="phase_overrides_section" style="display: none;">
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 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,109 @@ $(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
+ var phases = planPhases[$planSelect.val()] || [];
182
+ var seen = {};
183
+ return phases.filter(function(p) {
184
+ if (!p || !p.type || seen[p.type]) return false;
185
+ seen[p.type] = true;
186
+ return true;
187
+ });
188
+ }
189
+
190
+ function phaseLabel(p) {
191
+ var title = p.type.charAt(0) + p.type.slice(1).toLowerCase();
192
+ return p.priceLabel ? title + ' (' + p.priceLabel + ')' : title;
193
+ }
194
+
195
+ function selectedTypes($exceptRow) {
196
+ return $rows.find('.phase-override-row').filter(function() {
197
+ return !$exceptRow || this !== $exceptRow[0];
198
+ }).map(function() { return $(this).find('.phase-override-type').val(); }).get();
199
+ }
200
+
201
+ function refreshAddBtn() {
202
+ var phases = currentPhases();
203
+ var used = selectedTypes().filter(function(type) { return type; });
204
+ var hasMore = phases.length > 0 && used.length < phases.length;
205
+ if (hasMore) {
206
+ $addBtn.css('display', 'flex');
207
+ } else {
208
+ $addBtn.hide();
209
+ }
210
+ $headers.css('display', $rows.find('.phase-override-row').length > 0 ? 'flex' : 'none');
211
+ }
212
+
213
+ function rebuildRowOptions() {
214
+ var phases = currentPhases();
215
+ $rows.find('.phase-override-row').each(function() {
216
+ var $row = $(this);
217
+ var $select = $row.find('.phase-override-type');
218
+ var current = $select.val();
219
+ var used = selectedTypes($row);
220
+ $select.empty();
221
+ phases.forEach(function(p) {
222
+ if (used.indexOf(p.type) === -1) {
223
+ $select.append($('<option>', { value: p.type, text: phaseLabel(p) }));
224
+ }
225
+ });
226
+ var currentStillValid = phases.some(function(p) { return p.type === current; }) && used.indexOf(current) === -1;
227
+ if (currentStillValid) $select.val(current);
228
+ });
229
+ refreshAddBtn();
230
+ }
231
+
232
+ function addRow(preferredType) {
233
+ var phases = currentPhases();
234
+ if (phases.length === 0) return;
235
+ var used = selectedTypes().filter(function(type) { return type; });
236
+ var next = preferredType && phases.some(function(p) { return p.type === preferredType; }) && used.indexOf(preferredType) === -1
237
+ ? preferredType
238
+ : (phases.find(function(p) { return used.indexOf(p.type) === -1; }) || {}).type;
239
+ if (!next) return;
240
+
241
+ var $row = $($template.html());
242
+ $rows.append($row);
243
+ rebuildRowOptions();
244
+ $row.find('.phase-override-type').val(next);
245
+ rebuildRowOptions();
246
+ }
247
+
248
+ function resetRows() {
249
+ $rows.empty();
250
+ var phases = currentPhases();
251
+ if (phases.length > 0) addRow(phases[0].type);
252
+ refreshAddBtn();
253
+ }
254
+
255
+ $('#toggle_phase_overrides').on('click', function() {
256
+ $section.toggle();
257
+ $('#toggle_phase_overrides_caret').html($section.is(':visible') ? '&#9662;' : '&#9656;');
258
+ });
259
+
260
+ $addBtn.on('click', function() { addRow(); });
261
+
262
+ $rows.on('change', '.phase-override-type', rebuildRowOptions);
263
+ $rows.on('click', '.phase-override-remove', function() {
264
+ $(this).closest('.phase-override-row').remove();
265
+ refreshAddBtn();
266
+ });
267
+
268
+ $planSelect.on('change', resetRows);
269
+ resetRows();
270
+ }
127
271
  <% 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-control', title: (@unit_type.presence || @unit_types.first) %>
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,40 @@
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
+ <%
48
+ record_date_value = @record_date.to_s.strip
49
+ record_date_date_value = Time.now.utc.strftime('%Y-%m-%d')
50
+ record_date_time_value = '00:00'
51
+
52
+ begin
53
+ if record_date_value.match?(/^\d{4}-\d{2}-\d{2}$/)
54
+ record_date_date_value = record_date_value
55
+ elsif record_date_value.match?(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)
56
+ record_date_date_value = record_date_value[0, 10]
57
+ record_date_time_value = record_date_value[11, 5]
58
+ elsif !record_date_value.empty?
59
+ parsed_record_date = Time.iso8601(record_date_value).utc
60
+ record_date_date_value = parsed_record_date.strftime('%Y-%m-%d')
61
+ record_date_time_value = parsed_record_date.strftime('%H:%M')
62
+ end
63
+ rescue ArgumentError
64
+ record_date_date_value = Time.now.utc.strftime('%Y-%m-%d')
65
+ record_date_time_value = '00:00'
66
+ end
67
+ %>
68
+
69
+ <div class="d-flex gap-2 align-items-center">
70
+ <%= date_field_tag :record_date_date,
71
+ record_date_date_value,
72
+ required: true,
73
+ class: 'form-control' %>
74
+ <%= time_field_tag :record_date_time,
75
+ record_date_time_value,
76
+ step: 60,
77
+ class: 'form-control' %>
78
+ </div>
79
+ <%= hidden_field_tag :record_date, "#{record_date_date_value}T#{record_date_time_value}" %>
80
+ <small class="form-text text-muted">Pick a date. Time is optional and defaults to 00:00 (UTC) if unchanged.</small>
47
81
  </div>
48
82
  </div>
49
83
 
@@ -54,20 +88,6 @@
54
88
  </div>
55
89
  </div>
56
90
 
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
91
  <div class="form-group d-flex justify-content-end pb-3">
72
92
  <%= render "kaui/components/button/button", {
73
93
  label: 'Close',
@@ -95,10 +115,19 @@
95
115
  document.addEventListener('DOMContentLoaded', function () {
96
116
  var form = document.getElementById('record_usage_form');
97
117
  if (!form) { return; }
118
+ var unitTypeSelect = document.getElementById('unit_type');
119
+ if (unitTypeSelect && unitTypeSelect.tagName === 'SELECT') {
120
+ unitTypeSelect.title = unitTypeSelect.value || '';
121
+ unitTypeSelect.addEventListener('change', function () {
122
+ unitTypeSelect.title = unitTypeSelect.value || '';
123
+ });
124
+ }
125
+
98
126
  form.addEventListener('submit', function (e) {
99
127
  var errors = [];
100
128
 
101
- var unitType = (document.getElementById('unit_type').value || '').trim();
129
+ var unitTypeEl = document.getElementById('unit_type');
130
+ var unitType = unitTypeEl ? (unitTypeEl.value || '').trim() : '';
102
131
  if (unitType.length === 0) {
103
132
  errors.push('Unit type is required.');
104
133
  }
@@ -109,12 +138,29 @@
109
138
  errors.push('Amount must be a positive integer.');
110
139
  }
111
140
 
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).');
141
+ var dateEl = document.getElementById('record_date_date');
142
+ var timeEl = document.getElementById('record_date_time');
143
+ var hiddenDateEl = document.getElementById('record_date');
144
+
145
+ var datePart = dateEl ? (dateEl.value || '').trim() : '';
146
+ var timePart = timeEl ? (timeEl.value || '').trim() : '';
147
+ if (timePart.length === 0) {
148
+ timePart = '00:00';
149
+ }
150
+
151
+ var dateRegex = /^\d{4}-\d{2}-\d{2}$/;
152
+ var timeRegex = /^\d{2}:\d{2}$/;
153
+ if (datePart.length === 0) {
154
+ errors.push('Date/time of usage is required.');
155
+ } else if (!dateRegex.test(datePart)) {
156
+ errors.push('Date of usage must be in YYYY-MM-DD format.');
157
+ } else if (!timeRegex.test(timePart)) {
158
+ errors.push('Time must be in HH:MM format.');
159
+ } else {
160
+ var composedDateTime = datePart + 'T' + timePart;
161
+ if (hiddenDateEl) {
162
+ hiddenDateEl.value = composedDateTime;
163
+ }
118
164
  }
119
165
 
120
166
  if (errors.length > 0) {
@@ -98,6 +98,7 @@ en:
98
98
  unable_to_connect_database: "Unable to connect to the database."
99
99
  unable_to_connect_killbill: "Unable to connect to the Kill Bill server."
100
100
  error_communicating_killbill: "Error while communicating with the Kill Bill server."
101
+ locale_required: "Please specify a Locale (e.g. en_US) for the translation file."
101
102
  views:
102
103
  subscriptions:
103
104
  requested_date_for_billing_notice: <ul>
data/config/routes.rb CHANGED
@@ -202,6 +202,7 @@ Kaui::Engine.routes.draw do
202
202
  post '/modify_overdue_config' => 'admin_tenants#modify_overdue_config', :as => 'admin_tenant_modify_overdue_config'
203
203
  post '/upload_overdue_config' => 'admin_tenants#upload_overdue_config', :as => 'admin_tenant_upload_overdue_config'
204
204
  post '/upload_invoice_template' => 'admin_tenants#upload_invoice_template', :as => 'admin_tenant_upload_invoice_template'
205
+ get '/:id/invoice_template' => 'admin_tenants#invoice_template', :as => 'admin_tenant_invoice_template'
205
206
  post '/upload_invoice_translation' => 'admin_tenants#upload_invoice_translation', :as => 'admin_tenant_upload_invoice_translation'
206
207
  post '/upload_catalog_translation' => 'admin_tenants#upload_catalog_translation', :as => 'admin_tenant_upload_catalog_translation'
207
208
  post '/upload_plugin_config' => 'admin_tenants#upload_plugin_config', :as => 'admin_tenant_upload_plugin_config'
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.20'
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.20
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-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack