From 93ea169f786e068180d518a32ae42f145d9c9efb Mon Sep 17 00:00:00 2001 From: Tungle Duy Date: Fri, 21 Aug 2026 23:30:17 +0700 Subject: [PATCH 1/9] Remove GROUP BY dropdown and sort timeline by date then event type Remove the Group by drop-down and make date-first, event-type-second the permanent default ordering. Events on the same date are ordered by the canonical eventsOrder list (e.g. START_BILLING before STOP_BILLING). Fixes #649 Co-Authored-By: Claude Sonnet 4.6 --- .../kaui/account_timelines/show.html.erb | 55 ++++++------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/app/views/kaui/account_timelines/show.html.erb b/app/views/kaui/account_timelines/show.html.erb index 73032eef..b3352a51 100644 --- a/app/views/kaui/account_timelines/show.html.erb +++ b/app/views/kaui/account_timelines/show.html.erb @@ -49,13 +49,6 @@ -
-
- - <%= select_tag 'group-by', options_for_select([['Object Type', 'type'], ['Date', 'date']], 'type'), :class => 'form-control' %> -
-
-
<% if @account.present? %> <% @invoices.each do |invoice_stub| %> @@ -359,36 +352,29 @@ }); } - var originalTimelineEntriesOrder = null; - - function sortTimelineEntries(sortByDate) { + function sortTimelineEntries() { var $container = $('#timeline-entries'); var $entries = $container.children('[data-event-type]'); - if (originalTimelineEntriesOrder === null) { - // Cache the initial (grouped-by-object-type) DOM order the first time we need it - originalTimelineEntriesOrder = $entries.get(); - } - - var orderedEntries; - if (sortByDate) { - orderedEntries = $entries.get().sort(function (a, b) { - var x = parseInt($(a).attr('data-timestamp'), 10); - var y = parseInt($(b).attr('data-timestamp'), 10); - x = isNaN(x) ? -Infinity : x; - y = isNaN(y) ? -Infinity : y; - // Most recent first; entries without a usable timestamp sort last + var orderedEntries = $entries.get().sort(function (a, b) { + var x = parseInt($(a).attr('data-timestamp'), 10); + var y = parseInt($(b).attr('data-timestamp'), 10); + x = isNaN(x) ? -Infinity : x; + y = isNaN(y) ? -Infinity : y; + if (y !== x) { return y - x; - }); - } else { - orderedEntries = originalTimelineEntriesOrder; - } + } + var typeX = eventsOrder.indexOf($(a).attr('data-event-type')); + var typeY = eventsOrder.indexOf($(b).attr('data-event-type')); + typeX = typeX === -1 ? Infinity : typeX; + typeY = typeY === -1 ? Infinity : typeY; + return typeX - typeY; + }); $.each(orderedEntries, function (_, entry) { $container.append(entry); }); - // Re-apply any active filters, since the DOM order just changed filterTimeline(); } @@ -465,13 +451,6 @@ searchTimeout = setTimeout(filterTimeline, 300); }); - $('#group-by').on('change', function() { - sortTimelineEntries($(this).val() === 'date'); - }); - - - - // Clear all filters button $('#clear-all-filters').click(function() { $('#search').val(''); @@ -481,8 +460,8 @@ $('#billing_filter').val('all'); filterTimeline(); }); - - // Apply initial filters if any are selected - filterTimeline(); + + // Sort by date first, event type second, then apply filters + sortTimelineEntries(); }); <% end %> \ No newline at end of file From e0e1bd595e855091fb37faf899eff65bb9faa464 Mon Sep 17 00:00:00 2001 From: Tungle Duy Date: Sun, 23 Aug 2026 17:40:42 +0700 Subject: [PATCH 2/9] Add ability to create and view blocking states (#612) Implements the write and read paths for Kill Bill blocking states: Read path: - Account Timeline now surfaces blocking state events (previously filtered out) - Added dedicated "Blocking States" filter alongside existing Subscription/Payment/Billing filters - CSV export includes blocking state events - New "Blocking States" index page per account (nav sidebar entry) Write path: - "Create Blocking State" form for Account, Bundle, and Subscription levels - Accessible via Account Billing Information section button, Subscriptions table row dropdown, and the new Blocking States index page - Form includes Applies To summary, State/Service fields with helper text, Block flags (Change/Entitlement/Billing), Effective Date, and Comment - "About Blocking States" info panel alongside the form (i18n strings in en.yml) Co-Authored-By: Claude Sonnet 5 --- .../kaui/blocking-states/shield-white.svg | 3 + .../images/kaui/sidebar/blocking-states.svg | 4 + app/assets/images/kaui/timeline/block.svg | 4 + app/assets/stylesheets/kaui/subscription.css | 38 +++ .../account_blocking_states_controller.rb | 25 ++ .../kaui/account_timelines_controller.rb | 30 +- app/controllers/kaui/accounts_controller.rb | 15 + app/controllers/kaui/bundles_controller.rb | 14 + .../kaui/subscriptions_controller.rb | 14 + .../account_blocking_states/index.html.erb | 117 ++++++++ .../kaui/account_timelines/show.html.erb | 277 +++++++++++++----- .../kaui/accounts/_billing_details.html.erb | 14 +- .../kaui/accounts/_billing_info.html.erb | 6 +- app/views/kaui/accounts/block.html.erb | 16 + app/views/kaui/bundles/block.html.erb | 16 + .../blocking_state_form/_about_panel.html.erb | 9 + .../blocking_state_form/_fields.html.erb | 79 +++++ .../layouts/kaui_account_sidebar.html.erb | 3 +- .../_subscriptions_table.html.erb | 16 + app/views/kaui/subscriptions/block.html.erb | 16 + config/locales/en.yml | 4 + config/routes.rb | 10 + .../kaui/account_timelines_controller_test.rb | 37 +++ .../kaui/accounts_controller_test.rb | 32 ++ .../kaui/bundles_controller_test.rb | 35 +++ .../kaui/subscriptions_controller_test.rb | 34 +++ 26 files changed, 780 insertions(+), 88 deletions(-) create mode 100644 app/assets/images/kaui/blocking-states/shield-white.svg create mode 100644 app/assets/images/kaui/sidebar/blocking-states.svg create mode 100644 app/assets/images/kaui/timeline/block.svg create mode 100644 app/controllers/kaui/account_blocking_states_controller.rb create mode 100644 app/views/kaui/account_blocking_states/index.html.erb create mode 100644 app/views/kaui/accounts/block.html.erb create mode 100644 app/views/kaui/bundles/block.html.erb create mode 100644 app/views/kaui/components/blocking_state_form/_about_panel.html.erb create mode 100644 app/views/kaui/components/blocking_state_form/_fields.html.erb create mode 100644 app/views/kaui/subscriptions/block.html.erb diff --git a/app/assets/images/kaui/blocking-states/shield-white.svg b/app/assets/images/kaui/blocking-states/shield-white.svg new file mode 100644 index 00000000..4babaa3c --- /dev/null +++ b/app/assets/images/kaui/blocking-states/shield-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/kaui/sidebar/blocking-states.svg b/app/assets/images/kaui/sidebar/blocking-states.svg new file mode 100644 index 00000000..995fac5c --- /dev/null +++ b/app/assets/images/kaui/sidebar/blocking-states.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/kaui/timeline/block.svg b/app/assets/images/kaui/timeline/block.svg new file mode 100644 index 00000000..b3f88347 --- /dev/null +++ b/app/assets/images/kaui/timeline/block.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/stylesheets/kaui/subscription.css b/app/assets/stylesheets/kaui/subscription.css index 988da87c..e687883b 100644 --- a/app/assets/stylesheets/kaui/subscription.css +++ b/app/assets/stylesheets/kaui/subscription.css @@ -437,6 +437,44 @@ table tr.expired td { right: 0.5rem; } +/* app/views/kaui/components/blocking_state_form/_about_panel.html.erb */ + +.blocking-state-about-panel { + flex: 0 0 20rem; + max-width: 20rem; + height: fit-content; + padding: 1.25rem; + background-color: #EFF6FF; + border: 0.0625rem solid #DBEAFE; + border-radius: 0.5rem; +} + +.blocking-state-about-panel .icon-container { + display: inline-flex; + justify-content: center; + align-items: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + background-color: #2563EB; + flex-shrink: 0; +} + +.blocking-state-about-panel h6 { + font-weight: 600; + font-size: 1rem; + line-height: 1.5rem; + color: #1B1C1E; +} + +.blocking-state-about-panel p { + font-size: 0.875rem; + font-weight: 400; + line-height: 1.25rem; + color: #414651; + margin-bottom: 0; +} + .kaui-subscription-new .form-group.d-flex.pb-3 .form-control { height: 2.5rem; border-radius: 0.375rem; diff --git a/app/controllers/kaui/account_blocking_states_controller.rb b/app/controllers/kaui/account_blocking_states_controller.rb new file mode 100644 index 00000000..8ec8f3be --- /dev/null +++ b/app/controllers/kaui/account_blocking_states_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Kaui + class AccountBlockingStatesController < Kaui::EngineController + def index + options = options_for_klient + @account = Kaui::Account.find_by_id_or_key(params.require(:account_id), false, false, options) + blocking_states = @account.blocking_states(nil, nil, 'NONE', options) + + formatter = lambda do |bs| + object_type = bs.type == 'SUBSCRIPTION_BUNDLE' ? 'BUNDLE' : bs.type + url = view_context.url_for_object(bs.blocked_id, object_type) + [ + object_type, + url ? view_context.link_to(bs.blocked_id, url) : bs.blocked_id, + bs.service, + bs.state_name, + [('Change' if bs.is_block_change), ('Entitlement' if bs.is_block_entitlement), ('Billing' if bs.is_block_billing)].compact.join(', '), + bs.effective_date + ] + end + @blocking_states_json = blocking_states.map { |bs| formatter.call(bs) }.to_json + end + end +end diff --git a/app/controllers/kaui/account_timelines_controller.rb b/app/controllers/kaui/account_timelines_controller.rb index d86c00ff..457b3a53 100644 --- a/app/controllers/kaui/account_timelines_controller.rb +++ b/app/controllers/kaui/account_timelines_controller.rb @@ -11,6 +11,7 @@ def show @invoices = timeline.invoices @payments = timeline.payments extract_invoices_by_id(@invoices) + @account_blocking_states = fetch_account_blocking_states(@account) # Lookup all bundle names @bundle_names = {} @@ -51,6 +52,7 @@ def download @invoices = timeline.invoices @payments = timeline.payments extract_invoices_by_id(@invoices) + @account_blocking_states = fetch_account_blocking_states(@account) # Lookup all bundle names @bundle_names = {} @@ -117,20 +119,30 @@ def download @bundles.each do |bundle| bundle.subscriptions.each do |sub| sub.events.each do |event| - # Skip SERVICE_STATE_CHANGE events - next if event.event_type == 'SERVICE_STATE_CHANGE' - effective_date = event.effective_date.present? ? Date.parse(event.effective_date).to_s : '[unknown]' bundle_keys = @bundle_names[bundle.external_key] - event_type = event.event_type - phase = event.phase audit_logs = event.audit_logs.present? ? event.audit_logs.map { |entry| Kaui::AuditLog.description(entry) }.join(', ') : '' + details = if event.event_type == 'SERVICE_STATE_CHANGE' + "#{event.service_name}/#{event.service_state_name} (billing=#{event.is_blocked_billing}, entitlement=#{event.is_blocked_entitlement})" + else + event.phase + end - csv << [effective_date, bundle_keys, event_type, phase, audit_logs] if filter_date?(effective_date, start_date, end_date) + csv << [effective_date, bundle_keys, event.event_type, details, audit_logs] if filter_date?(effective_date, start_date, end_date) end end end end + + if %w[ENTITLEMENT ALL].include?(event_type) + @account_blocking_states.each do |blocking_state| + effective_date = blocking_state.effective_date.present? ? Date.parse(blocking_state.effective_date).to_s : '[unknown]' + audit_logs = blocking_state.audit_logs.present? ? blocking_state.audit_logs.map { |entry| Kaui::AuditLog.description(entry) }.join(', ') : '' + details = "#{blocking_state.service}/#{blocking_state.state_name} (billing=#{blocking_state.is_block_billing}, entitlement=#{blocking_state.is_block_entitlement}, change=#{blocking_state.is_block_change})" + + csv << [effective_date, '', 'SERVICE_STATE_CHANGE', details, audit_logs] if filter_date?(effective_date, start_date, end_date) + end + end end send_data csv_string, filename: "account-timelines-#{Time.zone.today}.csv", type: 'text/csv' @@ -161,5 +173,11 @@ def extract_invoices_by_id(all_invoices) [invoice.invoice_id, Kaui::Invoice.build_from_raw_invoice(invoice)] end end + + def fetch_account_blocking_states(account) + account.blocking_states(nil, nil, 'NONE', options_for_klient) + rescue StandardError + [] + end end end diff --git a/app/controllers/kaui/accounts_controller.rb b/app/controllers/kaui/accounts_controller.rb index 0ee2c0bc..284f358a 100644 --- a/app/controllers/kaui/accounts_controller.rb +++ b/app/controllers/kaui/accounts_controller.rb @@ -391,5 +391,20 @@ def export_account data = KillBillClient::Model::Export.find_by_account_id(params[:account_id], current_user.kb_username, options_for_klient) send_data data, filename: "account#{params[:account_id]}.txt", type: :txt end + + def block + @account = Kaui::Account.find_by_id(params.require(:account_id), false, false, options_for_klient) + end + + def do_block + account_id = params.require(:account_id) + account = Kaui::Account.new(account_id:) + account.set_blocking_state(params.require(:state_name), params.require(:service), + params[:is_block_change] == '1', params[:is_block_entitlement] == '1', + params[:is_block_billing] == '1', params[:requested_date].presence, + current_user.kb_username, params[:reason], params[:comment], options_for_klient) + + redirect_to account_path(account_id), notice: 'Blocking state was successfully created' + end end end diff --git a/app/controllers/kaui/bundles_controller.rb b/app/controllers/kaui/bundles_controller.rb index 585daafb..e99f83c7 100644 --- a/app/controllers/kaui/bundles_controller.rb +++ b/app/controllers/kaui/bundles_controller.rb @@ -128,6 +128,20 @@ def do_pause_resume redirect_to kaui_engine.account_bundles_path(@account.account_id), notice: msg end + def block + @bundle = Kaui::Bundle.find_by_id_or_key(params.require(:id), options_for_klient) + end + + def do_block + bundle = Kaui::Bundle.new(bundle_id: params.require(:id)) + bundle.set_blocking_state(params.require(:state_name), params.require(:service), + params[:is_block_change] == '1', params[:is_block_entitlement] == '1', + params[:is_block_billing] == '1', params[:requested_date].presence, + current_user.kb_username, params[:reason], params[:comment], options_for_klient) + + redirect_to kaui_engine.account_bundles_path(params.require(:account_id)), notice: 'Blocking state was successfully created' + end + private def search_bundles(query, search_by, options) diff --git a/app/controllers/kaui/subscriptions_controller.rb b/app/controllers/kaui/subscriptions_controller.rb index 77bc2d62..429d8dc8 100644 --- a/app/controllers/kaui/subscriptions_controller.rb +++ b/app/controllers/kaui/subscriptions_controller.rb @@ -150,6 +150,20 @@ def update_bcd redirect_to kaui_engine.account_bundles_path(input_subscription['account_id']), notice: 'Subscription BCD was successfully changed' end + def block + @subscription = Kaui::Subscription.find_by_id(params.require(:id), 'NONE', options_for_klient) + end + + def do_block + subscription = Kaui::Subscription.new(subscription_id: params.require(:id)) + subscription.set_blocking_state(params.require(:state_name), params.require(:service), + params[:is_block_change] == '1', params[:is_block_entitlement] == '1', + params[:is_block_billing] == '1', params[:requested_date].presence, + current_user.kb_username, params[:reason], params[:comment], options_for_klient) + + redirect_to kaui_engine.account_bundles_path(params.require(:account_id)), notice: 'Blocking state was successfully created' + end + def edit_quantity @subscription = Kaui::Subscription.find_by_id(params.require(:id), 'NONE', options_for_klient) end diff --git a/app/views/kaui/account_blocking_states/index.html.erb b/app/views/kaui/account_blocking_states/index.html.erb new file mode 100644 index 00000000..6e7e2963 --- /dev/null +++ b/app/views/kaui/account_blocking_states/index.html.erb @@ -0,0 +1,117 @@ + + +<%= javascript_tag do %> + $(document).ready(function() { + var blockingStates = JSON.parse($("#blocking-states").val()); + var table = $('#blocking-states-table').DataTable({ + "dom": "<'row'r>t<'row mt-3'<'col-md-6'i><'col-md-6'p>>", + data: blockingStates + }); + + // Custom sorting functionality + (function() { + var currentSortColumn = -1; + var currentSortDirection = 'asc'; + $('#blocking-states-table thead .sortable-header').on('click', function() { + var columnIndex = parseInt($(this).data('column')); + var newDirection = (currentSortColumn === columnIndex && currentSortDirection === 'asc') ? 'desc' : 'asc'; + currentSortColumn = columnIndex; + currentSortDirection = newDirection; + updateSortIndicators($('#blocking-states-table'), columnIndex, newDirection); + table.order([columnIndex, newDirection]).draw(); + }); + })(); + + function updateSortIndicators($table, columnIndex, direction) { + $table.find('.sortable-header').removeClass('sort-asc-active sort-desc-active'); + $table.find('.sort-icon').removeClass('active'); + var $header = $table.find('.sortable-header[data-column="' + columnIndex + '"]'); + $header.addClass(direction + '-active'); + $header.find('.sort-' + direction).addClass('active'); + } + }); +<% end %> diff --git a/app/views/kaui/account_timelines/show.html.erb b/app/views/kaui/account_timelines/show.html.erb index b3352a51..7186976e 100644 --- a/app/views/kaui/account_timelines/show.html.erb +++ b/app/views/kaui/account_timelines/show.html.erb @@ -29,19 +29,25 @@
- <%= select_tag 'payment_filter', options_for_select(''.html_safe), :class => 'form-control' %> + <%= select_tag 'payment_filter', options_for_select(''.html_safe), :class => 'form-control' %>
- <%= select_tag 'subscription_filter', options_for_select(''.html_safe), :class => 'form-control' %> + <%= select_tag 'subscription_filter', options_for_select(''.html_safe), :class => 'form-control' %>
- <%= select_tag 'billing_filter', options_for_select(''.html_safe), :class => 'form-control' %> + <%= select_tag 'billing_filter', options_for_select(''.html_safe), :class => 'form-control' %> +
+
+
+ +
+ <%= select_tag 'blocking_state_filter', options_for_select(''.html_safe), :class => 'form-control' %>
@@ -60,7 +66,7 @@ <% else %> <% invoice = invoice_stub %> <% end %> -
+
<% if invoice.target_date.present? %> <%= invoice.target_date %> @@ -148,7 +154,7 @@ <% invoice = nil %> <% end %> <% payment.transactions.each do |transaction| %> -
+
<% if transaction.effective_date.present? %> <%= format_date(transaction.effective_date, @account.time_zone).html_safe %> @@ -228,61 +234,176 @@ <% @bundles.each do |bundle| %> <% bundle.subscriptions.each do |sub| %> <% sub.events.each do |event| %> - <% next if event.event_type == 'SERVICE_STATE_CHANGE' %> -
-
- <% if event.effective_date.present? %> - <%= format_date(event.effective_date, @account.time_zone).html_safe %> - <% else %> - [unknown] - <% end %> -
-