diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index 340fa9b5a..da053d891 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -4,7 +4,7 @@ # Products are used in store class API::ProductsController < API::ApiController before_action :authenticate_user!, except: %i[index show] - before_action :set_product, only: %i[update destroy] + before_action :set_product, only: %i[update clone destroy] def index @products = ProductService.list(params, current_user) @@ -35,6 +35,17 @@ class API::ProductsController < API::ApiController end end + def clone + authorize @product + + @product = ProductService.clone(@product, product_params) + if @product.save + render status: :ok + else + render json: @product.errors.full_messages, status: :unprocessable_entity + end + end + def destroy authorize @product ProductService.destroy(@product) diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts index 5059c5612..65f964430 100644 --- a/app/frontend/src/javascript/api/product.ts +++ b/app/frontend/src/javascript/api/product.ts @@ -91,6 +91,13 @@ export default class ProductAPI { return res?.data; } + static async clone (product: Product, data: Product): Promise { + const res: AxiosResponse = await apiClient.put(`/api/products/${product.id}/clone`, { + product: data + }); + return res?.data; + } + static async destroy (productId: number): Promise { const res: AxiosResponse = await apiClient.delete(`/api/products/${productId}`); return res?.data; diff --git a/app/frontend/src/javascript/components/store/clone-product-modal.tsx b/app/frontend/src/javascript/components/store/clone-product-modal.tsx new file mode 100644 index 000000000..630af729f --- /dev/null +++ b/app/frontend/src/javascript/components/store/clone-product-modal.tsx @@ -0,0 +1,70 @@ +/* eslint-disable */ +import React, { useState, useEffect } from 'react'; +import { SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import { FormInput } from '../form/form-input'; +import { FormSwitch } from '../form/form-switch'; +import { useTranslation } from 'react-i18next'; +import { FabModal, ModalSize } from '../base/fab-modal'; +import { Product } from '../../models/product'; +import ProductAPI from '../../api/product'; + +interface CloneProductModalProps { + isOpen: boolean, + toggleModal: () => void, + onSuccess: (product: Product) => void, + onError: (message: string) => void, + product: Product, +} + +/** + * Modal dialog to clone a product + */ +export const CloneProductModal: React.FC = ({ isOpen, toggleModal, onSuccess, onError, product }) => { + const { t } = useTranslation('admin'); + const { handleSubmit, register, control, formState, reset } = useForm({ defaultValues: { + name: product.name, + sku: product.sku, + is_active: false, + } }); + + /** + * Call product clone api + */ + const handleClone: SubmitHandler = (data: Product) => { + ProductAPI.clone(product, data).then((res) => { + reset(res); + onSuccess(res); + }).catch(onError); + }; + + return ( + +
+ + + {product.is_active && + + } + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/edit-product.tsx b/app/frontend/src/javascript/components/store/edit-product.tsx index 7e2dfabaa..20680df9f 100644 --- a/app/frontend/src/javascript/components/store/edit-product.tsx +++ b/app/frontend/src/javascript/components/store/edit-product.tsx @@ -33,10 +33,17 @@ const EditProduct: React.FC = ({ productId, onSuccess, onError /** * Success to save product and return to product list + * or + * Success to clone product and return to new product */ - const saveProductSuccess = () => { - onSuccess(t('app.admin.store.edit_product.successfully_updated')); - window.location.href = '/#!/admin/store/products'; + const saveProductSuccess = (data: Product) => { + if (data.id === product.id) { + onSuccess(t('app.admin.store.edit_product.successfully_updated')); + window.location.href = '/#!/admin/store/products'; + } else { + onSuccess(t('app.admin.store.edit_product.successfully_cloned')); + window.location.href = `/#!/admin/store/products/${data.id}/edit`; + } }; if (product) { diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index 2effe3895..a6eb4610e 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -19,6 +19,7 @@ import MachineAPI from '../../api/machine'; import ProductAPI from '../../api/product'; import { Plus } from 'phosphor-react'; import { ProductStockForm } from './product-stock-form'; +import { CloneProductModal } from './clone-product-modal'; import ProductLib from '../../lib/product'; import { UnsavedFormAlert } from '../form/unsaved-form-alert'; import { UIRouter } from '@uirouter/angularjs'; @@ -54,6 +55,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc const [productCategories, setProductCategories] = useState([]); const [machines, setMachines] = useState([]); const [stockTab, setStockTab] = useState(false); + const [openCloneModal, setOpenCloneModal] = useState(false); useEffect(() => { ProductCategoryAPI.index().then(data => { @@ -136,6 +138,13 @@ export const ProductForm: React.FC = ({ product, title, onSucc } }; + /** + * Toggle clone product modal + */ + const toggleCloneModal = () => { + setOpenCloneModal(!openCloneModal); + }; + /** * Add new product file */ @@ -235,6 +244,12 @@ export const ProductForm: React.FC = ({ product, title, onSucc

{title}

+ {product.id && + <> + {t('app.admin.store.product_form.clone')} + + + } {t('app.admin.store.product_form.save')}
diff --git a/app/policies/product_policy.rb b/app/policies/product_policy.rb index 865dc7e96..179c4a722 100644 --- a/app/policies/product_policy.rb +++ b/app/policies/product_policy.rb @@ -10,6 +10,10 @@ class ProductPolicy < ApplicationPolicy user.privileged? end + def clone? + user.privileged? + end + def destroy? user.privileged? end diff --git a/app/services/product_service.rb b/app/services/product_service.rb index 926424dac..85ce4f963 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -67,6 +67,27 @@ class ProductService product end + def clone(product, product_params) + new_product = product.dup + new_product.name = product_params[:name] + new_product.sku = product_params[:sku] + new_product.is_active = product_params[:is_active] + new_product.stock['internal'] = 0 + new_product.stock['external'] = 0 + new_product.machine_ids = product.machine_ids + new_product.machine_ids = product.machine_ids + product.product_images.each do |image| + pi = new_product.product_images.build + pi.is_main = image.is_main + pi.attachment = File.open(image.attachment.file.file) + end + product.product_files.each do |file| + pf = new_product.product_files.build + pf.attachment = File.open(file.attachment.file.file) + end + new_product + end + def destroy(product) used_in_order = OrderItem.joins(:order).where.not('orders.state' => 'cart') .exists?(orderable: product) diff --git a/app/views/api/products/clone.json.jbuilder b/app/views/api/products/clone.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/clone.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 2e6a46945..37dafd322 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -2003,6 +2003,7 @@ en: successfully_created: "The new product has been created." edit_product: successfully_updated: "The product has been updated." + successfully_cloned: "The product has been cloned." product_form: product_parameters: "Product parameters" stock_management: "Stock management" @@ -2029,6 +2030,7 @@ en: product_images_info: "Advice
We advise you to use a square format, JPG or PNG. For JPG, please use white for the background colour. The main visual will be the first presented in the product sheet." add_product_image: "Add an image" save: "Save" + clone: "Clone" product_stock_form: stock_up_to_date: "Stock up to date" date_time: "{DATE} - {TIME}" @@ -2072,6 +2074,9 @@ en: damaged: "Damaged product" other_in: "Other (in)" other_out: "Other (out)" + clone_product_model: + clone_product: "Clone the product" + clone: "Clone" orders: heading: "Orders" create_order: "Create an order" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index cb0464e35..fb9d58c56 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -2003,6 +2003,7 @@ fr: successfully_created: "Le nouveau produit a été créé." edit_product: successfully_updated: "Le produit a été modifié." + successfully_cloned: "Le produit a été dupliqué." product_form: product_parameters: "Paramètres du produit" stock_management: "Gestion des stocks" @@ -2029,6 +2030,7 @@ fr: product_images_info: "Conseil
Nous vous conseillons d'utiliser un format carré, JPG ou PNG. Pour le JPG, veuillez utiliser le blanc pour la couleur de fond. Le visuel principal sera le premier présenté dans la fiche produit." add_product_image: "Ajouter une image" save: "Enregistrer" + clone: "Dupliquer" product_stock_form: stock_up_to_date: "Stock à jour" date_time: "{DATE} - {TIME}" @@ -2072,6 +2074,9 @@ fr: damaged: "Produit endommagé" other_in: "Autre (entrant)" other_out: "Autre (sortant)" + clone_product_model: + clone_product: "Dupliquer le produit" + clone: "Dupliquer" orders: heading: "Commandes" create_order: "Créer une commande" diff --git a/config/routes.rb b/config/routes.rb index 8df19c224..b3ed0cbb6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -155,6 +155,7 @@ Rails.application.routes.draw do end resources :products do + put 'clone', on: :member get 'stock_movements', on: :member end resources :cart, only: %i[create] do