diff --git a/backend/app/controllers/spree/admin/price_list_items_controller.rb b/backend/app/controllers/spree/admin/price_list_items_controller.rb new file mode 100644 index 00000000000..224d784347a --- /dev/null +++ b/backend/app/controllers/spree/admin/price_list_items_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Spree + module Admin + class PriceListItemsController < ResourceController + belongs_to 'spree/price_list', find_by: :id + before_action :set_price_list + + def new + @price_list_item = @price_list.price_list_items.build + @price_list_item.build_price + end + + private + + def set_price_list + @price_list = Spree::PriceList.find(params[:price_list_id]) + end + + def location_after_save + edit_admin_price_list_path(@price_list) + end + + def price_list_item_params + params.require(:price_list_item).permit(:price_list_id, price_attributes: [:amount, :currency, :variant_id]) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/price_lists_controller.rb b/backend/app/controllers/spree/admin/price_lists_controller.rb new file mode 100644 index 00000000000..09609bc17fb --- /dev/null +++ b/backend/app/controllers/spree/admin/price_lists_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Spree + module Admin + class PriceListsController < ResourceController + private + + def location_after_save + edit_admin_price_list_path(@price_list) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/user_groups_controller.rb b/backend/app/controllers/spree/admin/user_groups_controller.rb index 81cf1828579..de20678f555 100644 --- a/backend/app/controllers/spree/admin/user_groups_controller.rb +++ b/backend/app/controllers/spree/admin/user_groups_controller.rb @@ -3,6 +3,13 @@ module Spree module Admin class UserGroupsController < ResourceController + before_action :load_data, except: :index + + private + + def load_data + @price_list = Spree::PriceList.order(:name) + end end end end diff --git a/backend/app/views/spree/admin/price_list_items/_form.html.erb b/backend/app/views/spree/admin/price_list_items/_form.html.erb new file mode 100644 index 00000000000..3fe7dbf6a1a --- /dev/null +++ b/backend/app/views/spree/admin/price_list_items/_form.html.erb @@ -0,0 +1,33 @@ +
+
+
+ <%= f.label :variant %> + <%= f.collection_select :variant_id, Spree::Variant.all, :id, :descriptive_name, + { include_blank: true }, + { class: "select2 fullwidth", disabled: !f.object.new_record? } %> +
+
+ <%= f.label :country %> + <%= f.field_hint :country %> + <%= f.collection_select :country_iso, available_countries(restrict_to_zone: nil), :iso, :name, + { include_blank: t(:any_country, scope: [:spree, :admin, :prices]), selected: @price_list.try(:country).try(:iso) || nil }, + { class: 'custom-select fullwidth', disabled: true } %> + <%= f.hidden_field :country_iso, value: @price_list.try(:country).try(:iso) || nil %> +
+
+ <%= f.label :price %> + <%= render "spree/admin/shared/number_with_currency", f: f, amount_attr: :price, currency_attr: :currency, currency: @price_list.currency %> + <%= f.hidden_field :currency, value: @price_list.currency %> +
+
+ <%= field_container :contain_taxes, class: %w(checkbox) do %> + + <%= error_message_on :price_list, :contain_taxes %> + <% end %> +
+
+
+
diff --git a/backend/app/views/spree/admin/price_list_items/edit.html.erb b/backend/app/views/spree/admin/price_list_items/edit.html.erb new file mode 100644 index 00000000000..b0abde937d1 --- /dev/null +++ b/backend/app/views/spree/admin/price_list_items/edit.html.erb @@ -0,0 +1,22 @@ +<% admin_breadcrumb(link_to plural_resource_name(Spree::Product), spree.admin_products_path) %> +<% admin_breadcrumb(link_to(plural_resource_name(Spree::PriceList), spree.admin_price_lists_path)) %> +<% admin_breadcrumb(link_to(@price_list.name, edit_admin_price_list_path(@price_list))) %> + +<% admin_breadcrumb(link_to (plural_resource_name(Spree::Price)), edit_admin_price_list_path(@price_list)) %> +<% admin_breadcrumb(t('spree.actions.edit')) %> + + +<%= form_for [:admin, @price_list, @price_list_item] do |f| %> +
+ <%= t('.edit_price') %> + + <%= f.fields_for :price do |price_form| %> + <%= render 'form', f: price_form %> + <% end %> +
+ <%= submit_tag t('spree.actions.update'), class: 'btn btn-primary' %> + <%= link_to t('spree.actions.cancel'), edit_admin_price_list_path(@price_list), class: 'button' %> +
+ +
+<% end %> diff --git a/backend/app/views/spree/admin/price_list_items/new.html.erb b/backend/app/views/spree/admin/price_list_items/new.html.erb new file mode 100644 index 00000000000..084d6d8a205 --- /dev/null +++ b/backend/app/views/spree/admin/price_list_items/new.html.erb @@ -0,0 +1,21 @@ +<% admin_breadcrumb(link_to plural_resource_name(Spree::Product), spree.admin_products_path) %> +<% admin_breadcrumb(link_to(plural_resource_name(Spree::PriceList), spree.admin_price_lists_path)) %> +<% admin_breadcrumb(link_to(@price_list.name, edit_admin_price_list_path(@price_list))) %> + +<% admin_breadcrumb(link_to (plural_resource_name(Spree::Price)), edit_admin_price_list_path(@price_list)) %> +<% admin_breadcrumb(t('spree.actions.new')) %> + +<%= form_for [:admin, @price_list, @price_list_item] do |f| %> +
+ <%= t('.new_price') %> + + <%= f.fields_for :price do |price_form| %> + <%= render 'form', f: price_form %> + <% end %> +
+ <%= submit_tag t('spree.actions.create'), class: 'btn btn-primary' %> + <%= link_to t('spree.actions.cancel'), edit_admin_price_list_path(@price_list), class: 'button' %> +
+ +
+<% end %> diff --git a/backend/app/views/spree/admin/price_lists/_form.html.erb b/backend/app/views/spree/admin/price_lists/_form.html.erb new file mode 100644 index 00000000000..25d4823c298 --- /dev/null +++ b/backend/app/views/spree/admin/price_lists/_form.html.erb @@ -0,0 +1,43 @@ +
+
+ <%= f.field_container :name do %> + <%= f.label :name, class: 'required' %> + <%= f.text_field :name, class: 'fullwidth' %> + <%= error_message_on :price_list, :name %> + <% end %> +
+ +
+ <%= f.field_container :country do %> + <%= f.label :country %> + <%= f.field_hint :country %> + <%= f.collection_select :country_iso, available_countries(restrict_to_zone: nil), :iso, :name, + { + include_blank: t(:any_country, scope: [:spree, :admin, :prices]) + }, + { class: 'custom-select fullwidth', disabled: !f.object.new_record? } %> + <% end %> +
+ +
+ <%= f.field_container :currency do %> + <%= f.label :currency %> + <%= f.field_hint :currency %> + <%= f.select :currency, + Spree::Config.available_currencies.map(&:iso_code), + { include_blank: true }, + { class: 'custom-select fullwidth' } %> + <%= error_message_on :price_list, :currency %> + <% end %> +
+ +
+ <%= f.field_container :contain_taxes, class: %w(checkbox) do %> + + <%= error_message_on :price_list, :contain_taxes %> + <% end %> +
+
diff --git a/backend/app/views/spree/admin/price_lists/_price_list_info.html.erb b/backend/app/views/spree/admin/price_lists/_price_list_info.html.erb new file mode 100644 index 00000000000..2ceed2eb462 --- /dev/null +++ b/backend/app/views/spree/admin/price_lists/_price_list_info.html.erb @@ -0,0 +1,35 @@ +<% if price_list.prices.any? %> +
+ <%= t(".price_details") %> + + + + + + + + + + + + <% price_list.price_list_items.each do |price_item| %> + "> + + + + + + + <% end %> + +
<%= Spree::Variant.model_name.human %> <%= Spree::Price.human_attribute_name(:country) %><%= Spree::Price.human_attribute_name(:currency) %><%= Spree::Price.human_attribute_name(:amount) %>
<%= price_item.price.variant.descriptive_name %><%= price_item.price.display_country %><%= price_item.price.currency %><%= price_item.price.money.to_html %> + <% if can?(:edit, price_item) %> + <%= link_to_edit_url(edit_admin_price_list_price_list_item_path(price_list, price_item), no_text: true) unless price_item.price.discarded? %> + <% end %> + <% if can?(:destroy, price_item) %> +   + <%= link_to_delete(price_item, url: admin_price_list_price_list_item_path(price_list, price_item), no_text: true) unless price_item.price.discarded? %> + <% end %> +
+
+<% end %> diff --git a/backend/app/views/spree/admin/price_lists/edit.html.erb b/backend/app/views/spree/admin/price_lists/edit.html.erb new file mode 100644 index 00000000000..da4f19f59f3 --- /dev/null +++ b/backend/app/views/spree/admin/price_lists/edit.html.erb @@ -0,0 +1,29 @@ +<% admin_breadcrumb(link_to plural_resource_name(Spree::Product), spree.admin_products_path) %> +<% admin_breadcrumb(link_to plural_resource_name(Spree::PriceList), spree.admin_price_lists_path) %> +<% admin_breadcrumb(@price_list.name) %> + +<% content_for :page_actions do %> + +<% end if can?(:create, Spree::PriceList) %> + +
+ <%= render partial: 'spree/shared/error_messages', locals: { target: @price_list } %> +
+ +
+ <%= form_for [:admin, @price_list] do |f| %> +
+ <%= render partial: 'form', locals: { f: f } %> + +
+ + <%= render partial: 'price_list_info', locals: { price_list: @price_list } %> + +
+ <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
+
+ <% end %> +
diff --git a/backend/app/views/spree/admin/price_lists/index.html.erb b/backend/app/views/spree/admin/price_lists/index.html.erb new file mode 100644 index 00000000000..45e74054006 --- /dev/null +++ b/backend/app/views/spree/admin/price_lists/index.html.erb @@ -0,0 +1,54 @@ +<% admin_breadcrumb(link_to plural_resource_name(Spree::Product), spree.admin_products_path) %> +<% admin_breadcrumb(plural_resource_name(Spree::PriceList)) %> + +<% content_for :page_actions do %> + <% if can?(:create, Spree::PriceList) %> +
  • + <%= link_to t('spree.new_price_list'), new_object_url, id: 'admin_new_price_list_link', class: 'btn btn-primary' %> +
  • + <% end %> +<% end %> + +<% if @price_lists.any? %> + + + + + + + + + + + + + + + <% @price_lists.each do |price_list| %> + + + + + + <% end %> + +
    <%= Spree::PriceList.human_attribute_name(:name) %><%= Spree::UserGroup.model_name.human.pluralize %>
    <%= link_to price_list.try(:name), edit_admin_price_list_path(price_list) %> + + <%= price_list.user_groups.count %> <%= Spree::UserGroup.model_name.human.pluralize %> + + + <% if can?(:update, price_list) %> + <%= link_to_edit price_list, no_text: true %> + <% end %> + + <% if can?(:destroy, price_list) %> + <%= link_to_delete price_list, no_text: true %> + <% end %> +
    +<% else %> +
    + <%= render 'spree/admin/shared/no_objects_found', + resource: Spree::PriceList, + new_resource_url: new_object_url %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/price_lists/new.html.erb b/backend/app/views/spree/admin/price_lists/new.html.erb new file mode 100644 index 00000000000..09643171f7d --- /dev/null +++ b/backend/app/views/spree/admin/price_lists/new.html.erb @@ -0,0 +1,24 @@ +<% admin_breadcrumb(link_to plural_resource_name(Spree::Product), spree.admin_products_path) %> +<% admin_breadcrumb(link_to plural_resource_name(Spree::PriceList), spree.admin_price_lists_path) %> +<% admin_breadcrumb(t('spree.new_price_list')) %> + +<% content_for :page_actions do %> +<% end %> + +
    + <%= render partial: 'spree/shared/error_messages', locals: { target: @price_list } %> +
    + +
    + <%= form_for [:admin, @price_list] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + +
    + +
    + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +
    + <% end %> +
    diff --git a/backend/app/views/spree/admin/shared/_product_sub_menu.html.erb b/backend/app/views/spree/admin/shared/_product_sub_menu.html.erb index 02f468756d6..6313168f3c0 100644 --- a/backend/app/views/spree/admin/shared/_product_sub_menu.html.erb +++ b/backend/app/views/spree/admin/shared/_product_sub_menu.html.erb @@ -4,6 +4,9 @@ <% if can? :admin, Spree::Product %> <%= tab label: :products, match_path: '/products' %> <% end %> + <% if can? :admin, Spree::PriceList %> + <%= tab label: :price_lists, match_path: '/price_lists' %> + <% end %> <% if can? :admin, Spree::OptionType %> <%= tab label: :option_types, match_path: '/option_types' %> <% end %> diff --git a/backend/app/views/spree/admin/user_groups/_form.html.erb b/backend/app/views/spree/admin/user_groups/_form.html.erb index 2ee861195bc..f1e22ba03aa 100644 --- a/backend/app/views/spree/admin/user_groups/_form.html.erb +++ b/backend/app/views/spree/admin/user_groups/_form.html.erb @@ -6,4 +6,12 @@ <%= error_message_on :user_group, :group_name %> <% end %> + +
    + <%= f.field_container :price_list_id do %> + <%= f.label :price_list_id %>
    + <%= f.collection_select :price_list_id, @price_list, :id, :name, { include_blank: true }, { class: "custom-select" } %> + <%= error_message_on :user_group, :price_list_id %> + <% end %> +
    diff --git a/backend/app/views/spree/admin/user_groups/index.html.erb b/backend/app/views/spree/admin/user_groups/index.html.erb index 717ac92c5cc..71d537021c3 100644 --- a/backend/app/views/spree/admin/user_groups/index.html.erb +++ b/backend/app/views/spree/admin/user_groups/index.html.erb @@ -12,12 +12,14 @@ <% if @user_groups.any? %> + + @@ -25,6 +27,7 @@ <% @user_groups.each do |user_group| %> +
    <%= Spree::UserGroup.human_attribute_name(:group_name) %><%= Spree::PriceList.model_name.human %>
    <%= link_to user_group.try(:group_name), edit_admin_user_group_path(user_group) %><%= user_group.price_list.try(:name) %> <% if can?(:update, user_group) %> <%= link_to_edit user_group, no_text: true %> diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 656d822c730..5d999d06d3f 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -16,6 +16,10 @@ resources :zones + resources :price_lists do + resources :price_list_items, only: [:destroy, :edit, :update, :new, :create] + end + resources :user_groups resources :tax_categories diff --git a/backend/lib/spree/backend_configuration.rb b/backend/lib/spree/backend_configuration.rb index 690baa26838..7d8e603ee77 100644 --- a/backend/lib/spree/backend_configuration.rb +++ b/backend/lib/spree/backend_configuration.rb @@ -202,6 +202,13 @@ def menu_items condition: -> { can? :admin, Spree::Product }, match_path: '/products', ), + MenuItem.new( + label: :price_lists, + condition: -> { + can?(:admin, Spree::PriceList) + }, + match_path: '/price_lists' + ), MenuItem.new( label: :option_types, condition: -> { can? :admin, Spree::OptionType }, diff --git a/core/app/models/spree/price.rb b/core/app/models/spree/price.rb index 36a957468db..d6759a61557 100644 --- a/core/app/models/spree/price.rb +++ b/core/app/models/spree/price.rb @@ -9,6 +9,9 @@ class Price < Spree::Base belongs_to :variant, -> { with_discarded }, class_name: 'Spree::Variant', touch: true, optional: true belongs_to :country, class_name: "Spree::Country", foreign_key: "country_iso", primary_key: "iso", optional: true + has_one :price_list_item, class_name: 'Spree::PriceListItem' + has_one :price_list, through: :price_list_item, class_name: 'Spree::PriceList' + delegate :product, to: :variant delegate :tax_rates, to: :variant diff --git a/core/app/models/spree/price_list.rb b/core/app/models/spree/price_list.rb new file mode 100644 index 00000000000..cfbfa2cfac8 --- /dev/null +++ b/core/app/models/spree/price_list.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Spree + class PriceList < Spree::Base + has_many :price_list_items, class_name: 'Spree::PriceListItem', dependent: :destroy + has_many :prices, through: :price_list_items, class_name: 'Spree::Price' + has_many :user_groups, class_name: 'Spree::UserGroup', dependent: :destroy + belongs_to :country, class_name: "Spree::Country", foreign_key: "country_iso", primary_key: "iso", optional: true + + validates :currency, inclusion: { in: ::Money::Currency.all.map(&:iso_code), message: :invalid_code } + validates :country, presence: true, unless: -> { for_any_country? } + + validates :name, presence: true + + def for_any_country? + country_iso.nil? + end + + def country_iso=(country_iso) + self[:country_iso] = country_iso.presence + end + end +end diff --git a/core/app/models/spree/price_list_item.rb b/core/app/models/spree/price_list_item.rb new file mode 100644 index 00000000000..be129317edb --- /dev/null +++ b/core/app/models/spree/price_list_item.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Spree + class PriceListItem < Spree::Base + belongs_to :price_list, class_name: 'Spree::PriceList' + belongs_to :price, class_name: 'Spree::Price', dependent: :destroy + + validates :price_list, presence: true + validates :price, presence: true + + accepts_nested_attributes_for :price + end +end diff --git a/core/app/models/spree/user_group.rb b/core/app/models/spree/user_group.rb index 89e69250cb2..96d4f1ddcf7 100644 --- a/core/app/models/spree/user_group.rb +++ b/core/app/models/spree/user_group.rb @@ -4,6 +4,7 @@ module Spree class UserGroup < Spree::Base has_many :users, class_name: Spree::UserClassHandle.new has_one :store, class_name: 'Spree::Store', foreign_key: 'default_cart_user_group_id' + belongs_to :price_list, class_name: 'Spree::PriceList', optional: true validates :group_name, presence: true end diff --git a/core/config/locales/en.yml b/core/config/locales/en.yml index 1864ac24615..929e85596fa 100644 --- a/core/config/locales/en.yml +++ b/core/config/locales/en.yml @@ -479,6 +479,10 @@ en: attributes: currency: invalid_code: is not a valid currency code + spree/price_list: + attributes: + currency: + invalid_code: is not a valid currency code spree/promotion: attributes: apply_automatically: @@ -647,6 +651,9 @@ en: spree/price: one: Price other: Prices + spree/price_list: + one: Price List + other: Price Lists spree/product: one: Product other: Products @@ -874,6 +881,16 @@ en: source_forms: storecredit: not_supported: Creating store credit payments via the admin is not currently supported. + price_list_items: + edit: + edit_price: Edit Price + new: + new_price: New Price + price_lists: + edit: + new_price: Add New Price + price_list_info: + price_details: Price Details prices: any_country: Any Country edit: @@ -952,6 +969,8 @@ en: orders: Orders overview: Overview payments: Payments + price_list: Price List + price_lists: Price Lists products: Products properties: Property Types rma: RMA @@ -1155,6 +1174,7 @@ en: confirm_delete: Confirm Deletion confirm_order: Confirm Order confirm_password: Password Confirmation + contain_taxes: Tax Included continue: Continue continue_shopping: Continue shopping cost_currency: Cost Currency @@ -1616,6 +1636,9 @@ en: country: 'This determines in what country the price is valid.
    Default: Any Country' master_variant: Changing master variant prices will not change variant prices below, but will be used to populate all new variants options: These options are used to create variants in the variants table. They can be changed in the variants tab + spree/price_list: + country: 'This determines in what country the price is valid.
    Default: Any Country' + currency: This determines which currency will be used for the price list's prices. Please, be aware that this will be used while adding prices to the price list's. spree/product: available_on: This sets the availability date for the product. If this value is not set, or it is set to a date in the future, then the product is not available on the storefront. discontinue_on: This sets the discontinue date for the product. If this value is set to a date, then the product is not available on the storefront from that day on anymore. @@ -1794,6 +1817,7 @@ en: new_order_completed: New Order Completed new_payment: New Payment new_payment_method: New Payment Method + new_price_list: New Price List new_product: New Product new_property: New Property Type new_refund: New Refund diff --git a/core/db/migrate/20250409090546_create_price_lists.rb b/core/db/migrate/20250409090546_create_price_lists.rb new file mode 100644 index 00000000000..63f831259c0 --- /dev/null +++ b/core/db/migrate/20250409090546_create_price_lists.rb @@ -0,0 +1,12 @@ +class CreatePriceLists < ActiveRecord::Migration[7.0] + def change + create_table :spree_price_lists do |t| + t.string :name + t.string :country_iso, limit: 2 + t.string :currency + t.boolean :contain_taxes, default: false + t.index ["country_iso"], name: "index_spree_price_lists_on_country_iso" + t.timestamps + end + end +end diff --git a/core/db/migrate/20250409090608_create_price_list_items.rb b/core/db/migrate/20250409090608_create_price_list_items.rb new file mode 100644 index 00000000000..762b9c9a0cd --- /dev/null +++ b/core/db/migrate/20250409090608_create_price_list_items.rb @@ -0,0 +1,9 @@ +class CreatePriceListItems < ActiveRecord::Migration[7.0] + def change + create_table :spree_price_list_items do |t| + t.references :price_list, foreign_key: { to_table: :spree_price_lists } + t.references :price, type: :integer, foreign_key: { to_table: :spree_prices } + t.timestamps + end + end +end diff --git a/core/db/migrate/20250409090748_add_contain_taxes_to_prices.rb b/core/db/migrate/20250409090748_add_contain_taxes_to_prices.rb new file mode 100644 index 00000000000..2ca150aa122 --- /dev/null +++ b/core/db/migrate/20250409090748_add_contain_taxes_to_prices.rb @@ -0,0 +1,5 @@ +class AddContainTaxesToPrices < ActiveRecord::Migration[7.0] + def change + add_column :spree_prices, :contain_taxes, :boolean, default: false + end +end diff --git a/core/db/migrate/20250409093925_add_price_list_to_user_group.rb b/core/db/migrate/20250409093925_add_price_list_to_user_group.rb new file mode 100644 index 00000000000..d19b53fe30a --- /dev/null +++ b/core/db/migrate/20250409093925_add_price_list_to_user_group.rb @@ -0,0 +1,7 @@ +class AddPriceListToUserGroup < ActiveRecord::Migration[7.0] + def change + change_table :spree_user_groups do |t| + t.references :price_list, foreign_key: { to_table: :spree_price_lists } + end + end +end