mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 08:34:26 +00:00
* feat: create merchant inline from transaction detail Add a searchable "create or select" merchant combobox (DS::MerchantSelect, mirroring the existing DS::TagSelect pattern) so a new FamilyMerchant can be created directly from the transaction detail and new-transaction forms, instead of requiring a trip to Settings > Merchants first. FamilyMerchantsController#create now also responds to JSON so the combobox can create-and-select a merchant without a full page reload. * fix: address PR review feedback on merchant inline creation - Handle Turbo validation failures in FamilyMerchantsController#create (missing format.turbo_stream branch raised ActionController::UnknownFormat) - Guard connect() so disabled merchant selectors (no menu target) don't throw - Select the exact match (or block form submission) on Enter when the create row is hidden - Catch network/parse failures in createMerchant and show an error - Localize the fallback "could not create merchant" error message - Move the create button/error text outside the listbox and add aria-controls for accessibility - Align create-option avatar size with the selected-value display Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: keep created merchant option inside the listbox, move logo URL logic to component - Add a dedicated listbox target and insert newly created merchant options inside it (previously landed outside role="listbox" after the create button was moved out in the prior review-fix commit) - Move selected-merchant logo URL transformation out of the template into DS::MerchantSelect#selected_merchant_logo_url Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: address maintainer review feedback on merchant inline creation - Drop the explicit format.turbo_stream branch in the merchant creation failure path: respond_to's turbo_stream matching forces the response Content-Type to text/vnd.turbo-stream.html before the block runs, so render :new, formats: [:html] only changed template lookup, not the Content-Type. Turbo's client then received a turbo-stream response with no <turbo-stream> tags and silently did nothing. Leaving turbo_stream undeclared lets Rails negotiate down to format.html, which renders :new with the correct text/html type. - Add truncate/shrink-0 classes to the merchant option partial's name and avatar spans so they match the selected-value display once updateSelectionDisplay clones them into the trigger button. - Add a regression test simulating a Turbo form submission (Accept: turbo-stream, text/html) against a duplicate merchant name. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
191 lines
7.7 KiB
Ruby
191 lines
7.7 KiB
Ruby
class FamilyMerchantsController < ApplicationController
|
|
before_action :set_merchant, only: %i[edit update destroy]
|
|
|
|
def index
|
|
@breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.merchants"), nil ] ]
|
|
|
|
# Show all merchants for this family
|
|
@all_family_merchants = Current.family.merchants.alphabetically
|
|
@all_provider_merchants = Current.family.assigned_merchants_for(Current.user).where(type: "ProviderMerchant").alphabetically
|
|
|
|
family_scope = @all_family_merchants
|
|
if params[:family_search].is_a?(String) && params[:family_search].strip.present?
|
|
family_scope = family_scope.where("LOWER(name) LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(params[:family_search].strip.downcase)}%")
|
|
end
|
|
|
|
provider_scope = @all_provider_merchants
|
|
if params[:provider_search].is_a?(String) && params[:provider_search].strip.present?
|
|
provider_scope = provider_scope.where("LOWER(name) LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(params[:provider_search].strip.downcase)}%")
|
|
end
|
|
|
|
# Show recently unlinked ProviderMerchants (within last 30 days)
|
|
# Exclude merchants that are already assigned to transactions (they appear in provider_merchants)
|
|
recently_unlinked_ids = FamilyMerchantAssociation
|
|
.where(family: Current.family)
|
|
.recently_unlinked
|
|
.pluck(:merchant_id)
|
|
assigned_ids = @all_provider_merchants.pluck(:id)
|
|
@unlinked_merchants = ProviderMerchant.where(id: recently_unlinked_ids - assigned_ids).alphabetically
|
|
|
|
@enhanceable_count = @all_provider_merchants.where(website_url: [ nil, "" ]).count
|
|
@llm_available = Provider::Registry.get_provider(:openai).present?
|
|
|
|
@pagy_family_merchants, @family_merchants = pagy(family_scope, page_param: :family_page, limit: safe_per_page)
|
|
@pagy_provider_merchants, @provider_merchants = pagy(provider_scope, page_param: :provider_page, limit: safe_per_page)
|
|
|
|
render layout: "settings"
|
|
end
|
|
|
|
def new
|
|
@family_merchant = FamilyMerchant.new(family: Current.family)
|
|
end
|
|
|
|
def create
|
|
@family_merchant = FamilyMerchant.new(merchant_params.merge(family: Current.family))
|
|
|
|
if @family_merchant.save
|
|
respond_to do |format|
|
|
format.html { redirect_to family_merchants_path, notice: t(".success") }
|
|
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
|
|
format.json { render json: merchant_json(@family_merchant), status: :created }
|
|
end
|
|
else
|
|
respond_to do |format|
|
|
# No explicit format.turbo_stream branch: Turbo's form submissions send an
|
|
# Accept header that prefers turbo-stream, but forcing that format here would
|
|
# lock the response's Content-Type to turbo-stream while still rendering the
|
|
# plain :new HTML template — Turbo's client then sees a turbo-stream
|
|
# Content-Type with no <turbo-stream> tags in the body and does nothing.
|
|
# Leaving turbo-stream undeclared lets Rails' content negotiation fall back to
|
|
# format.html below, which renders :new with the correct text/html type.
|
|
format.html { render :new, status: :unprocessable_entity }
|
|
format.json { render json: { errors: @family_merchant.errors.full_messages }, status: :unprocessable_entity }
|
|
end
|
|
end
|
|
end
|
|
|
|
def edit
|
|
end
|
|
|
|
def update
|
|
if @merchant.is_a?(ProviderMerchant)
|
|
if merchant_params[:name].present? && merchant_params[:name] != @merchant.name
|
|
# Name changed — convert ProviderMerchant to FamilyMerchant for this family only
|
|
@family_merchant = @merchant.convert_to_family_merchant_for(Current.family, merchant_params)
|
|
respond_to do |format|
|
|
format.html { redirect_to family_merchants_path, notice: t(".converted_success") }
|
|
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
|
|
end
|
|
else
|
|
# Only website changed — update the ProviderMerchant directly
|
|
@merchant.update!(merchant_params.slice(:website_url))
|
|
@merchant.generate_logo_url_from_website!
|
|
respond_to do |format|
|
|
format.html { redirect_to family_merchants_path, notice: t(".success") }
|
|
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
|
|
end
|
|
end
|
|
elsif @merchant.update(merchant_params)
|
|
respond_to do |format|
|
|
format.html { redirect_to family_merchants_path, notice: t(".success") }
|
|
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
|
|
end
|
|
else
|
|
render :edit, status: :unprocessable_entity
|
|
end
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
@family_merchant = e.record
|
|
render :edit, status: :unprocessable_entity
|
|
end
|
|
|
|
def destroy
|
|
if @merchant.is_a?(ProviderMerchant)
|
|
# Unlink from family's transactions only (don't delete the global merchant)
|
|
@merchant.unlink_from_family(Current.family)
|
|
redirect_to family_merchants_path, notice: t(".unlinked_success")
|
|
else
|
|
@merchant.destroy!
|
|
redirect_to family_merchants_path, notice: t(".success")
|
|
end
|
|
end
|
|
|
|
def enhance
|
|
cache_key = "enhance_provider_merchants:#{Current.family.id}"
|
|
|
|
already_running = !Rails.cache.write(cache_key, true, expires_in: 10.minutes, unless_exist: true)
|
|
|
|
if already_running
|
|
return redirect_to family_merchants_path, alert: t(".already_running")
|
|
end
|
|
|
|
EnhanceProviderMerchantsJob.perform_later(Current.family)
|
|
redirect_to family_merchants_path, notice: t(".success")
|
|
end
|
|
|
|
def merge
|
|
@merchants = all_family_merchants
|
|
end
|
|
|
|
def perform_merge
|
|
# Scope lookups to merchants valid for this family (FamilyMerchants + assigned ProviderMerchants)
|
|
valid_merchants = all_family_merchants
|
|
|
|
target = valid_merchants.find_by(id: params[:target_id])
|
|
unless target
|
|
return redirect_to merge_family_merchants_path, alert: t(".target_not_found")
|
|
end
|
|
|
|
sources = valid_merchants.where(id: params[:source_ids])
|
|
unless sources.any?
|
|
return redirect_to merge_family_merchants_path, alert: t(".invalid_merchants")
|
|
end
|
|
|
|
merger = Merchant::Merger.new(
|
|
family: Current.family,
|
|
target_merchant: target,
|
|
source_merchants: sources
|
|
)
|
|
|
|
if merger.merge!
|
|
redirect_to family_merchants_path, notice: t(".success", count: merger.merged_count)
|
|
else
|
|
redirect_to merge_family_merchants_path, alert: t(".no_merchants_selected")
|
|
end
|
|
rescue Merchant::Merger::UnauthorizedMerchantError => e
|
|
redirect_to merge_family_merchants_path, alert: e.message
|
|
end
|
|
|
|
private
|
|
def set_merchant
|
|
# Find merchant that either belongs to family OR is assigned to family's transactions
|
|
@merchant = Current.family.merchants.find_by(id: params[:id]) ||
|
|
Current.family.assigned_merchants.find(params[:id])
|
|
@family_merchant = @merchant # For backwards compatibility with views
|
|
end
|
|
|
|
def merchant_params
|
|
# Handle both family_merchant and provider_merchant param keys
|
|
key = params.key?(:family_merchant) ? :family_merchant : :provider_merchant
|
|
params.require(key).permit(:name, :color, :website_url)
|
|
end
|
|
|
|
def merchant_json(merchant)
|
|
merchant.as_json(only: %i[id name]).merge(
|
|
html: render_to_string(
|
|
partial: "DS/merchant_select/option",
|
|
formats: [ :html ],
|
|
locals: { merchant: merchant, selected: true, view_helpers: helpers }
|
|
)
|
|
)
|
|
end
|
|
|
|
def all_family_merchants
|
|
family_merchant_ids = Current.family.merchants.pluck(:id)
|
|
provider_merchant_ids = Current.family.assigned_merchants.where(type: "ProviderMerchant").pluck(:id)
|
|
combined_ids = (family_merchant_ids + provider_merchant_ids).uniq
|
|
|
|
Merchant.where(id: combined_ids)
|
|
.order(Arel.sql("LOWER(COALESCE(name, ''))"))
|
|
end
|
|
end
|