From 432b60ca9ab6f20fecf756b908f97390ff9a5b65 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 11 Jul 2022 19:17:36 +0200 Subject: [PATCH 001/361] store product category create/list/update/delete --- Gemfile | 7 +- Gemfile.lock | 3 + .../api/product_categories_controller.rb | 50 ++++++++ .../src/javascript/api/product-category.ts | 30 +++++ .../javascript/components/base/fab-input.tsx | 8 +- .../store/product-categories-list.tsx | 50 ++++++++ .../components/store/product-categories.tsx | 114 ++++++++++++++++++ .../store/product-category-form.tsx | 99 +++++++++++++++ .../store/product-category-modal.tsx | 100 +++++++++++++++ .../src/javascript/controllers/admin/store.js | 38 ++++++ .../src/javascript/controllers/main_nav.js | 6 + .../src/javascript/models/product-category.ts | 7 ++ app/frontend/src/javascript/router.js | 10 ++ .../templates/admin/store/categories.html | 1 + app/frontend/templates/admin/store/index.html | 42 +++++++ .../templates/admin/store/orders.html | 1 + .../templates/admin/store/products.html | 1 + .../templates/admin/store/settings.html | 1 + app/models/product_category.rb | 12 ++ app/policies/product_category_policy.rb | 16 +++ app/services/product_category_service.rb | 13 ++ .../_product_category.json.jbuilder | 3 + .../product_categories/create.json.jbuilder | 3 + .../product_categories/index.json.jbuilder | 5 + .../api/product_categories/show.json.jbuilder | 3 + .../product_categories/update.json.jbuilder | 3 + config/locales/app.admin.en.yml | 24 ++++ config/locales/app.admin.fr.yml | 24 ++++ config/locales/app.public.en.yml | 1 + config/locales/app.public.fr.yml | 1 + config/routes.rb | 2 + ...0220620072750_create_product_categories.rb | 12 ++ db/schema.rb | 30 +++-- package.json | 3 + yarn.lock | 28 +++++ 35 files changed, 733 insertions(+), 18 deletions(-) create mode 100644 app/controllers/api/product_categories_controller.rb create mode 100644 app/frontend/src/javascript/api/product-category.ts create mode 100644 app/frontend/src/javascript/components/store/product-categories-list.tsx create mode 100644 app/frontend/src/javascript/components/store/product-categories.tsx create mode 100644 app/frontend/src/javascript/components/store/product-category-form.tsx create mode 100644 app/frontend/src/javascript/components/store/product-category-modal.tsx create mode 100644 app/frontend/src/javascript/controllers/admin/store.js create mode 100644 app/frontend/src/javascript/models/product-category.ts create mode 100644 app/frontend/templates/admin/store/categories.html create mode 100644 app/frontend/templates/admin/store/index.html create mode 100644 app/frontend/templates/admin/store/orders.html create mode 100644 app/frontend/templates/admin/store/products.html create mode 100644 app/frontend/templates/admin/store/settings.html create mode 100644 app/models/product_category.rb create mode 100644 app/policies/product_category_policy.rb create mode 100644 app/services/product_category_service.rb create mode 100644 app/views/api/product_categories/_product_category.json.jbuilder create mode 100644 app/views/api/product_categories/create.json.jbuilder create mode 100644 app/views/api/product_categories/index.json.jbuilder create mode 100644 app/views/api/product_categories/show.json.jbuilder create mode 100644 app/views/api/product_categories/update.json.jbuilder create mode 100644 db/migrate/20220620072750_create_product_categories.rb diff --git a/Gemfile b/Gemfile index fa5abd22d..46fc79260 100644 --- a/Gemfile +++ b/Gemfile @@ -50,9 +50,9 @@ group :test do gem 'faker' gem 'minitest-reporters' gem 'pdf-reader' + gem 'rubyXL' gem 'vcr', '6.0.0' gem 'webmock' - gem 'rubyXL' end group :production, :staging do @@ -67,7 +67,6 @@ gem 'pg_search' # authentication gem 'devise', '>= 4.6.0' - gem 'omniauth', '~> 1.9.0' gem 'omniauth-oauth2' gem 'omniauth_openid_connect' @@ -145,4 +144,6 @@ gem 'tzinfo-data' # compilation of dynamic stylesheets (home page & theme) gem 'sassc', '= 2.1.0' -gem 'redis-session-store' \ No newline at end of file +gem 'redis-session-store' + +gem 'acts_as_list' diff --git a/Gemfile.lock b/Gemfile.lock index dfb8000e3..fdeff967b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -48,6 +48,8 @@ GEM i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) + acts_as_list (1.0.4) + activerecord (>= 4.2) addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) aes_key_wrap (1.1.0) @@ -494,6 +496,7 @@ DEPENDENCIES aasm actionpack-page_caching (= 1.2.2) active_record_query_trace + acts_as_list api-pagination apipie-rails awesome_print diff --git a/app/controllers/api/product_categories_controller.rb b/app/controllers/api/product_categories_controller.rb new file mode 100644 index 000000000..87a0949e3 --- /dev/null +++ b/app/controllers/api/product_categories_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# API Controller for resources of type ProductCategory +# ProductCategorys are used to group products +class API::ProductCategoriesController < API::ApiController + before_action :authenticate_user!, except: :index + before_action :set_product_category, only: %i[show update destroy] + + def index + @product_categories = ProductCategoryService.list + end + + def show; end + + def create + authorize ProductCategory + @product_category = ProductCategory.new(product_category_params) + if @product_category.save + render status: :created + else + render json: @product_category.errors.full_messages, status: :unprocessable_entity + end + end + + def update + authorize @product_category + + if @product_category.update(product_category_params) + render status: :ok + else + render json: @product_category.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + authorize @product_category + ProductCategoryService.destroy(@product_category) + head :no_content + end + + private + + def set_product_category + @product_category = ProductCategory.find(params[:id]) + end + + def product_category_params + params.require(:product_category).permit(:name, :parent_id, :slug, :position) + end +end diff --git a/app/frontend/src/javascript/api/product-category.ts b/app/frontend/src/javascript/api/product-category.ts new file mode 100644 index 000000000..964ef4e8f --- /dev/null +++ b/app/frontend/src/javascript/api/product-category.ts @@ -0,0 +1,30 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { ProductCategory } from '../models/product-category'; + +export default class ProductCategoryAPI { + static async index (): Promise> { + const res: AxiosResponse> = await apiClient.get('/api/product_categories'); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/product_categories/${id}`); + return res?.data; + } + + static async create (productCategory: ProductCategory): Promise { + const res: AxiosResponse = await apiClient.post('/api/product_categories', { product_category: productCategory }); + return res?.data; + } + + static async update (productCategory: ProductCategory): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/product_categories/${productCategory.id}`, { product_category: productCategory }); + return res?.data; + } + + static async destroy (productCategoryId: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/product_categories/${productCategoryId}`); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/base/fab-input.tsx b/app/frontend/src/javascript/components/base/fab-input.tsx index b5500ff84..f7e57dc5c 100644 --- a/app/frontend/src/javascript/components/base/fab-input.tsx +++ b/app/frontend/src/javascript/components/base/fab-input.tsx @@ -36,11 +36,9 @@ export const FabInput: React.FC = ({ id, onChange, defaultValue, * If the default value changes, update the value of the input until there's no content in it. */ useEffect(() => { - if (!inputValue) { - setInputValue(defaultValue); - if (typeof onChange === 'function') { - onChange(defaultValue); - } + setInputValue(defaultValue); + if (typeof onChange === 'function') { + onChange(defaultValue); } }, [defaultValue]); diff --git a/app/frontend/src/javascript/components/store/product-categories-list.tsx b/app/frontend/src/javascript/components/store/product-categories-list.tsx new file mode 100644 index 000000000..b818caabb --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-categories-list.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { FabButton } from '../base/fab-button'; +import { ProductCategory } from '../../models/product-category'; + +interface ProductCategoriesListProps { + productCategories: Array, + onEdit: (category: ProductCategory) => void, + onDelete: (categoryId: number) => void, +} + +/** + * This component shows a Tree list of all Product's Categories + */ +export const ProductCategoriesList: React.FC = ({ productCategories, onEdit, onDelete }) => { + /** + * Init the process of editing the given product category + */ + const editProductCategory = (category: ProductCategory): () => void => { + return (): void => { + onEdit(category); + }; + }; + + /** + * Init the process of delete the given product category + */ + const deleteProductCategory = (categoryId: number): () => void => { + return (): void => { + onDelete(categoryId); + }; + }; + + return ( +
+ {productCategories.map((category) => ( +
+ {category.name} +
+ + + + + + +
+
+ ))} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/product-categories.tsx b/app/frontend/src/javascript/components/store/product-categories.tsx new file mode 100644 index 000000000..6ae88f8a5 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-categories.tsx @@ -0,0 +1,114 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { HtmlTranslate } from '../base/html-translate'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { FabAlert } from '../base/fab-alert'; +import { FabButton } from '../base/fab-button'; +import { ProductCategoriesList } from './product-categories-list'; +import { ProductCategoryModal } from './product-category-modal'; +import { ProductCategory } from '../../models/product-category'; +import ProductCategoryAPI from '../../api/product-category'; + +declare const Application: IApplication; + +interface ProductCategoriesProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component shows a Tree list of all Product's Categories + */ +const ProductCategories: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const [isOpenProductCategoryModal, setIsOpenProductCategoryModal] = useState(false); + const [productCategories, setProductCategories] = useState>([]); + const [productCategory, setProductCategory] = useState(null); + + useEffect(() => { + ProductCategoryAPI.index().then(data => { + setProductCategories(data); + }); + }, []); + + /** + * Open create new product category modal + */ + const openProductCategoryModal = () => { + setIsOpenProductCategoryModal(true); + }; + + /** + * toggle create/edit product category modal + */ + const toggleCreateAndEditProductCategoryModal = () => { + setIsOpenProductCategoryModal(!isOpenProductCategoryModal); + }; + + /** + * callback handle save product category success + */ + const onSaveProductCategorySuccess = (message: string) => { + setIsOpenProductCategoryModal(false); + onSuccess(message); + ProductCategoryAPI.index().then(data => { + setProductCategories(data); + }); + }; + + /** + * Open edit the product category modal + */ + const editProductCategory = (category: ProductCategory) => { + setProductCategory(category); + setIsOpenProductCategoryModal(true); + }; + + /** + * Delete a product category + */ + const deleteProductCategory = async (categoryId: number): Promise => { + try { + await ProductCategoryAPI.destroy(categoryId); + const data = await ProductCategoryAPI.index(); + setProductCategories(data); + onSuccess(t('app.admin.store.product_categories.successfully_deleted')); + } catch (e) { + onError(t('app.admin.store.product_categories.unable_to_delete') + e); + } + }; + + return ( +
+

{t('app.admin.store.product_categories.the_categories')}

+ {t('app.admin.store.product_categories.create_a_product_category')} + + + + + +
+ ); +}; + +const ProductCategoriesWrapper: React.FC = ({ onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('productCategories', react2angular(ProductCategoriesWrapper, ['onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/product-category-form.tsx b/app/frontend/src/javascript/components/store/product-category-form.tsx new file mode 100644 index 000000000..9199d87a4 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-category-form.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import Select from 'react-select'; +import slugify from 'slugify'; +import { FabInput } from '../base/fab-input'; +import { ProductCategory } from '../../models/product-category'; + +interface ProductCategoryFormProps { + productCategories: Array, + productCategory?: ProductCategory, + onChange: (field: string, value: string | number) => void, +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +type selectOption = { value: number, label: string }; + +/** + * Form to set create/edit supporting documents type + */ +export const ProductCategoryForm: React.FC = ({ productCategories, productCategory, onChange }) => { + const { t } = useTranslation('admin'); + + // filter all first level product categorie + const parents = productCategories.filter(c => !c.parent_id); + + const [slug, setSlug] = useState(productCategory?.slug || ''); + + /** + * Return the default first level product category, formatted to match the react-select format + */ + const defaultValue = { value: productCategory?.parent_id, label: productCategory?.name }; + + /** + * Convert all parents to the react-select format + */ + const buildOptions = (): Array => { + return parents.map(t => { + return { value: t.id, label: t.name }; + }); + }; + + /** + * Callback triggered when the selection of parent product category has changed. + */ + const handleCategoryParentChange = (option: selectOption): void => { + onChange('parent_id', option.value); + }; + + /** + * Callback triggered when the name has changed. + */ + const handleNameChange = (value: string): void => { + onChange('name', value); + const _slug = slugify(value, { lower: true }); + setSlug(_slug); + onChange('slug', _slug); + }; + + /** + * Callback triggered when the slug has changed. + */ + const handleSlugChange = (value: string): void => { + onChange('slug', value); + }; + + return ( +
+
+
+ } + defaultValue={productCategory?.name || ''} + placeholder={t('app.admin.store.product_category_form.name')} + onChange={handleNameChange} + debounce={200} + required/> +
+
+ } + defaultValue={slug} + placeholder={t('app.admin.store.product_category_form.slug')} + onChange={handleSlugChange} + debounce={200} + required/> +
+
+ -
-
-
+
+ { action === 'delete' + ? <> + + {t('app.admin.store.product_category_form.delete.confirm')} + + {t('app.admin.store.product_category_form.save')} + + : <> + + + + {t('app.admin.store.product_category_form.save')} + + } + ); }; diff --git a/app/frontend/src/javascript/components/store/product-category-modal.tsx b/app/frontend/src/javascript/components/store/product-category-modal.tsx deleted file mode 100644 index c2f3185dd..000000000 --- a/app/frontend/src/javascript/components/store/product-category-modal.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { FabModal } from '../base/fab-modal'; -import { ProductCategoryForm } from './product-category-form'; -import { ProductCategory } from '../../models/product-category'; -import ProductCategoryAPI from '../../api/product-category'; - -interface ProductCategoryModalProps { - isOpen: boolean, - toggleModal: () => void, - onSuccess: (message: string) => void, - onError: (message: string) => void, - productCategories: Array, - productCategory?: ProductCategory, -} - -/** - * Check if string is a valid url slug - */ -function checkIfValidURLSlug (str: string): boolean { - // Regular expression to check if string is a valid url slug - const regexExp = /^[a-z0-9]+(?:-[a-z0-9]+)*$/g; - - return regexExp.test(str); -} - -/** - * Modal dialog to create/edit a category of product - */ -export const ProductCategoryModal: React.FC = ({ isOpen, toggleModal, onSuccess, onError, productCategories, productCategory }) => { - const { t } = useTranslation('admin'); - - const [data, setData] = useState({ - id: productCategory?.id, - name: productCategory?.name || '', - slug: productCategory?.slug || '', - parent_id: productCategory?.parent_id, - position: productCategory?.position - }); - - useEffect(() => { - setData({ - id: productCategory?.id, - name: productCategory?.name || '', - slug: productCategory?.slug || '', - parent_id: productCategory?.parent_id, - position: productCategory?.position - }); - }, [productCategory]); - - /** - * Callback triggered when an inner form field has changed: updates the internal state accordingly - */ - const handleChanged = (field: string, value: string | number) => { - setData({ - ...data, - [field]: value - }); - }; - - /** - * Save the current product category to the API - */ - const handleSave = async (): Promise => { - try { - if (productCategory?.id) { - await ProductCategoryAPI.update(data); - onSuccess(t('app.admin.store.product_category_modal.successfully_updated')); - } else { - await ProductCategoryAPI.create(data); - onSuccess(t('app.admin.store.product_category_modal.successfully_created')); - } - } catch (e) { - if (productCategory?.id) { - onError(t('app.admin.store.product_category_modal.unable_to_update') + e); - } else { - onError(t('app.admin.store.product_category_modal.unable_to_create') + e); - } - } - }; - - /** - * Check if the form is valid (not empty, url valid slug) - */ - const isPreventedSaveProductCategory = (): boolean => { - return !data.name || !data.slug || !checkIfValidURLSlug(data.slug); - }; - - return ( - - - - ); -}; diff --git a/app/frontend/src/javascript/controllers/main_nav.js b/app/frontend/src/javascript/controllers/main_nav.js index cd21250d7..c2a672e02 100644 --- a/app/frontend/src/javascript/controllers/main_nav.js +++ b/app/frontend/src/javascript/controllers/main_nav.js @@ -86,7 +86,7 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm { state: 'app.admin.store', linkText: 'app.public.common.manage_the_store', - linkIcon: 'cogs', + linkIcon: 'cart-plus', authorizedRoles: ['admin', 'manager'] }, $scope.$root.modules.trainings && { diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 14723c579..48fe7e9a3 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -85,6 +85,8 @@ @import "modules/settings/check-list-setting"; @import "modules/settings/user-validation-setting"; @import "modules/socials/fab-socials"; +@import "modules/store/manage-product-category"; +@import "modules/store/product-categories"; @import "modules/subscriptions/free-extend-modal"; @import "modules/subscriptions/renew-modal"; @import "modules/supporting-documents/supporting-documents-files"; diff --git a/app/frontend/src/stylesheets/modules/base/fab-button.scss b/app/frontend/src/stylesheets/modules/base/fab-button.scss index 5f70a2d3a..af0df298b 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-button.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-button.scss @@ -46,4 +46,7 @@ &--icon { margin-right: 0.5em; } + &--icon-only { + display: flex; + } } diff --git a/app/frontend/src/stylesheets/modules/base/fab-modal.scss b/app/frontend/src/stylesheets/modules/base/fab-modal.scss index 4e5d538b3..890c98c3b 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-modal.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-modal.scss @@ -81,6 +81,12 @@ position: relative; padding: 15px; + .subtitle { + margin-bottom: 3.2rem; + @include title-base; + color: var(--gray-hard-darkest); + } + form { display: flex; flex-direction: column; diff --git a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss index 59ac7a110..d8c897b48 100644 --- a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss +++ b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss @@ -64,6 +64,7 @@ border: 1px solid var(--gray-soft-dark); border-radius: var(--border-radius); transition: border-color ease-in-out 0.15s; + font-weight: 400; .icon, .addon { diff --git a/app/frontend/src/stylesheets/modules/store/manage-product-category.scss b/app/frontend/src/stylesheets/modules/store/manage-product-category.scss new file mode 100644 index 000000000..41a61d564 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/manage-product-category.scss @@ -0,0 +1,3 @@ +.manage-product-category { + +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss new file mode 100644 index 000000000..75963f5f2 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -0,0 +1,97 @@ +@mixin btn { + width: 4rem; + height: 4rem; + display: inline-flex; + justify-content: center; + align-items: center; + padding: 0; + background: none; + border: none; + &:active { + color: currentColor; + box-shadow: none; + } +} + +.product-categories { + max-width: 1300px; + margin: 0 auto; + + header { + padding: 2.4rem 0; + display: flex; + justify-content: space-between; + align-items: center; + h2 { + margin: 0; + @include title-lg; + color: var(--gray-hard-darkest); + } + } + + .create-button { + background-color: var(--gray-hard-darkest); + border-color: var(--gray-hard-darkest); + color: var(--gray-soft-lightest); + &:hover { + background-color: var(--gray-hard-light); + border-color: var(--gray-hard-light); + } + } + + &-list { + & > *:not(:last-of-type) { + margin-bottom: 1.6rem; + } + } + &-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + + .itemInfo { + display: flex; + justify-content: flex-end; + align-items: center; + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + &-count { + margin-left: 2.4rem; + @include text-sm; + font-weight: 500; + color: var(--information); + } + } + + .action { + display: flex; + justify-content: flex-end; + align-items: center; + .manage { + overflow: hidden; + display: flex; + border-radius: var(--border-radius); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-button {background: var(--gray-hard-darkest) } + .delete-button {background: var(--error) } + } + } + + .draghandle { + @include btn; + cursor: grab; + } + } +} \ No newline at end of file diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 545fbbe1c..ba8e8aad5 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1894,17 +1894,18 @@ en: title: "Documentation" content: "Click here to access the API online documentation." store: - manage_the_store: "Manage the Store Fablab" + manage_the_store: "Manage the Store" settings: "Settings" all_products: "All products" - categories_of_store: "Categories of store" + categories_of_store: "Store's categories" the_orders: "Orders" product_categories: - create_a_product_category: "Create a category" - the_categories: "Categories" + title: "Categories" info: "Information:
Find below all the categories created. The categories are arranged on two levels maximum, you can arrange them with a drag and drop. The order of the categories will be identical on the public view and the list below. Please note that you can delete a category or a sub-category even if they are associated with products. The latter will be left without categories. If you delete a category that contains sub-categories, the latter will also be deleted. Make sure that your categories are well arranged and save your choice." - successfully_deleted: "The category has been successfully deleted" - unable_to_delete: "Unable to delete the category: " + manage_product_category: + create: "Create a product category" + update: "Modify the product category" + delete: "Delete the product category" product_category_modal: successfully_created: "The new category has been created." unable_to_create: "Unable to create the category: " @@ -1912,8 +1913,12 @@ en: unable_to_update: "Unable to modify the category: " new_product_category: "Create a category" edit_product_category: "Modify a category" - save: "Save" product_category_form: name: "Name of category" slug: "Name of URL" select_parent_product_category: "Choose a parent category (N1)" + delete: + confirm: "Do you really want to delete this product category?" + error: "Unable to delete the category: " + success: "The category has been successfully deleted" + save: "Save" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 93ea64a4e..ff0d0982d 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1900,20 +1900,25 @@ fr: categories_of_store: "Les catégories de la boutique" the_orders: "Les commandes" product_categories: - create_a_product_category: "Créer une catégorie" - the_categories: "Les catégories" - info: "Information:
Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveux maximum, vous pouvez les agancer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. Veillez au bon agencement de vos catégories et sauvegarder votre choix." - successfully_deleted: "La catégorie a bien été supprimé" - unable_to_delete: "Impossible de supprimer the category: " + title: "Les catégories" + info: "Information:
Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveaux maximum, vous pouvez les agencer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. Veillez au bon agencement de vos catégories et sauvegarder votre choix." + manage_product_category: + create: "Create a product category" + update: "Update the product category" + delete: "Delete the product category" product_category_modal: successfully_created: "La catégorie a bien été créée." unable_to_create: "Impossible de créer la catégorie : " successfully_updated: "La nouvelle catégorie a bien été mise à jour." unable_to_update: "Impossible de modifier la catégorie : " new_product_category: "Créer une catégorie" - edit_product_category: "Modifier la catéogirie" - save: "Sauvgarder" + edit_product_category: "Modifier la catégorie" product_category_form: name: "Nom de la catégorie" slug: "Nom de l'URL" select_parent_product_category: "Choisir une catégorie parent (N1)" + delete: + confirm: "Do you really want to delete this product category?" + error: "Impossible de supprimer the catégorie : " + success: "La catégorie a bien été supprimée" + save: "Enregistrer" From 588ab696ae83011dd04f3ec1111f4af8da2d7ada Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 18 Jul 2022 14:57:33 +0200 Subject: [PATCH 006/361] Convert product category form to RHF --- .../store/manage-product-category.tsx | 2 +- .../components/store/product-categories.tsx | 3 +- .../store/product-category-form.tsx | 35 ++++++++++++++----- config/locales/app.admin.en.yml | 12 ++++--- config/locales/app.admin.fr.yml | 12 ++++--- 5 files changed, 46 insertions(+), 18 deletions(-) diff --git a/app/frontend/src/javascript/components/store/manage-product-category.tsx b/app/frontend/src/javascript/components/store/manage-product-category.tsx index 52dcb1557..28b99b63f 100644 --- a/app/frontend/src/javascript/components/store/manage-product-category.tsx +++ b/app/frontend/src/javascript/components/store/manage-product-category.tsx @@ -15,7 +15,7 @@ interface ManageProductCategoryProps { /** * This component shows a button. - * When clicked, we show a modal dialog allowing to fill the parameters of a product category (create new or update existing). + * When clicked, we show a modal dialog allowing to fill the parameters of a product category. */ export const ManageProductCategory: React.FC = ({ productCategories, productCategory, action, onSuccess, onError }) => { const { t } = useTranslation('admin'); diff --git a/app/frontend/src/javascript/components/store/product-categories.tsx b/app/frontend/src/javascript/components/store/product-categories.tsx index 17e72baaf..977bbe01f 100644 --- a/app/frontend/src/javascript/components/store/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/product-categories.tsx @@ -18,7 +18,8 @@ interface ProductCategoriesProps { } /** - * This component shows a Tree list of all Product's Categories + * This component shows a list of all product categories and offer to manager them + * by creating, deleting, modifying and reordering each product categories. */ const ProductCategories: React.FC = ({ onSuccess, onError }) => { const { t } = useTranslation('admin'); diff --git a/app/frontend/src/javascript/components/store/product-category-form.tsx b/app/frontend/src/javascript/components/store/product-category-form.tsx index 339765be7..9f1f31b54 100644 --- a/app/frontend/src/javascript/components/store/product-category-form.tsx +++ b/app/frontend/src/javascript/components/store/product-category-form.tsx @@ -29,7 +29,7 @@ interface ProductCategoryFormProps { export const ProductCategoryForm: React.FC = ({ action, productCategories, productCategory, onSuccess, onError }) => { const { t } = useTranslation('admin'); - const { register, watch, setValue, control, handleSubmit } = useForm({ defaultValues: { ...productCategory } }); + const { register, watch, setValue, control, handleSubmit, formState } = useForm({ defaultValues: { ...productCategory } }); // filter all first level product categorie const parents = productCategories.filter(c => !c.parent_id); @@ -53,15 +53,26 @@ export const ProductCategoryForm: React.FC = ({ action }); return () => subscription.unsubscribe(); }, [watch]); + // Check slug pattern + // Only lowercase alphanumeric groups of characters separated by an hyphen + const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/g; // Form submit const onSubmit: SubmitHandler = (category: ProductCategory) => { switch (action) { case 'create': - console.log('create:', category); + ProductCategoryAPI.create(category).then(() => { + onSuccess(t('app.admin.store.product_category_form.create.success')); + }).catch((error) => { + onError(t('app.admin.store.product_category_form.create.error') + error); + }); break; case 'update': - console.log('update:', category); + ProductCategoryAPI.update(category).then(() => { + onSuccess(t('app.admin.store.product_category_form.update.success')); + }).catch((error) => { + onError(t('app.admin.store.product_category_form.update.error') + error); + }); break; case 'delete': ProductCategoryAPI.destroy(category.id).then(() => { @@ -84,13 +95,21 @@ export const ProductCategoryForm: React.FC = ({ action : <> + register={register} + rules={{ required: `${t('app.admin.store.product_category_form.required')}` }} + formState={formState} + label={t('app.admin.store.product_category_form.name')} + defaultValue={productCategory?.name || ''} /> Date: Wed, 20 Jul 2022 08:53:54 +0200 Subject: [PATCH 007/361] Add subfolder in store --- .../manage-product-category.tsx | 6 +++--- .../product-categories-tree.tsx} | 12 ++++++------ .../{ => categories}/product-categories.tsx | 18 +++++++++--------- .../{ => categories}/product-category-form.tsx | 12 ++++++------ .../modules/store/product-categories.scss | 2 +- 5 files changed, 25 insertions(+), 25 deletions(-) rename app/frontend/src/javascript/components/store/{ => categories}/manage-product-category.tsx (93%) rename app/frontend/src/javascript/components/store/{product-categories-list.tsx => categories/product-categories-tree.tsx} (75%) rename app/frontend/src/javascript/components/store/{ => categories}/product-categories.tsx (80%) rename app/frontend/src/javascript/components/store/{ => categories}/product-category-form.tsx (93%) diff --git a/app/frontend/src/javascript/components/store/manage-product-category.tsx b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx similarity index 93% rename from app/frontend/src/javascript/components/store/manage-product-category.tsx rename to app/frontend/src/javascript/components/store/categories/manage-product-category.tsx index 28b99b63f..068c5294f 100644 --- a/app/frontend/src/javascript/components/store/manage-product-category.tsx +++ b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ProductCategory } from '../../models/product-category'; -import { FabButton } from '../base/fab-button'; -import { FabModal, ModalSize } from '../base/fab-modal'; +import { ProductCategory } from '../../../models/product-category'; +import { FabButton } from '../../base/fab-button'; +import { FabModal, ModalSize } from '../../base/fab-modal'; import { ProductCategoryForm } from './product-category-form'; interface ManageProductCategoryProps { diff --git a/app/frontend/src/javascript/components/store/product-categories-list.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx similarity index 75% rename from app/frontend/src/javascript/components/store/product-categories-list.tsx rename to app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx index 0d0baf74e..132a2328c 100644 --- a/app/frontend/src/javascript/components/store/product-categories-list.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx @@ -1,21 +1,21 @@ import React from 'react'; -import { ProductCategory } from '../../models/product-category'; +import { ProductCategory } from '../../../models/product-category'; import { DotsSixVertical } from 'phosphor-react'; -import { FabButton } from '../base/fab-button'; +import { FabButton } from '../../base/fab-button'; import { ManageProductCategory } from './manage-product-category'; -interface ProductCategoriesListProps { +interface ProductCategoriesTreeProps { productCategories: Array, onSuccess: (message: string) => void, onError: (message: string) => void, } /** - * This component shows a Tree list of all Product's Categories + * This component shows a tree list of all Product's Categories */ -export const ProductCategoriesList: React.FC = ({ productCategories, onSuccess, onError }) => { +export const ProductCategoriesTree: React.FC = ({ productCategories, onSuccess, onError }) => { return ( -
+
{productCategories.map((category) => (
diff --git a/app/frontend/src/javascript/components/store/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx similarity index 80% rename from app/frontend/src/javascript/components/store/product-categories.tsx rename to app/frontend/src/javascript/components/store/categories/product-categories.tsx index 977bbe01f..0900f56fa 100644 --- a/app/frontend/src/javascript/components/store/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -1,13 +1,13 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ProductCategory } from '../../models/product-category'; -import ProductCategoryAPI from '../../api/product-category'; +import { ProductCategory } from '../../../models/product-category'; +import ProductCategoryAPI from '../../../api/product-category'; import { ManageProductCategory } from './manage-product-category'; -import { ProductCategoriesList } from './product-categories-list'; -import { FabAlert } from '../base/fab-alert'; -import { HtmlTranslate } from '../base/html-translate'; -import { IApplication } from '../../models/application'; -import { Loader } from '../base/loader'; +import { ProductCategoriesTree } from './product-categories-tree'; +import { FabAlert } from '../../base/fab-alert'; +import { HtmlTranslate } from '../../base/html-translate'; +import { IApplication } from '../../../models/application'; +import { Loader } from '../../base/loader'; import { react2angular } from 'react2angular'; declare const Application: IApplication; @@ -18,7 +18,7 @@ interface ProductCategoriesProps { } /** - * This component shows a list of all product categories and offer to manager them + * This component shows a tree list of all product categories and offer to manager them * by creating, deleting, modifying and reordering each product categories. */ const ProductCategories: React.FC = ({ onSuccess, onError }) => { @@ -61,7 +61,7 @@ const ProductCategories: React.FC = ({ onSuccess, onErro -
diff --git a/app/frontend/src/javascript/components/store/product-category-form.tsx b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx similarity index 93% rename from app/frontend/src/javascript/components/store/product-category-form.tsx rename to app/frontend/src/javascript/components/store/categories/product-category-form.tsx index 9f1f31b54..87136b03e 100644 --- a/app/frontend/src/javascript/components/store/product-category-form.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx @@ -2,12 +2,12 @@ import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useForm, SubmitHandler } from 'react-hook-form'; import slugify from 'slugify'; -import { FormInput } from '../form/form-input'; -import { FormSelect } from '../form/form-select'; -import { ProductCategory } from '../../models/product-category'; -import { FabButton } from '../base/fab-button'; -import { FabAlert } from '../base/fab-alert'; -import ProductCategoryAPI from '../../api/product-category'; +import { FormInput } from '../../form/form-input'; +import { FormSelect } from '../../form/form-select'; +import { ProductCategory } from '../../../models/product-category'; +import { FabButton } from '../../base/fab-button'; +import { FabAlert } from '../../base/fab-alert'; +import ProductCategoryAPI from '../../../api/product-category'; interface ProductCategoryFormProps { action: 'create' | 'update' | 'delete', diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 75963f5f2..3aab16990 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -39,7 +39,7 @@ } } - &-list { + &-tree { & > *:not(:last-of-type) { margin-bottom: 1.6rem; } From 4ce68f33a8ed40bc8fff7409a0cf614d068c5c15 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 20 Jul 2022 11:54:46 +0200 Subject: [PATCH 008/361] change top position of product category to 0 --- app/models/product_category.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/product_category.rb b/app/models/product_category.rb index a17149171..8feb8afda 100644 --- a/app/models/product_category.rb +++ b/app/models/product_category.rb @@ -8,5 +8,5 @@ class ProductCategory < ApplicationRecord belongs_to :parent, class_name: 'ProductCategory' has_many :children, class_name: 'ProductCategory', foreign_key: :parent_id - acts_as_list scope: :parent + acts_as_list scope: :parent, top_of_list: 0 end From 1e3e7854b2716b1827e2fc622636cf7ca11d3c19 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 13 Jul 2022 15:06:46 +0200 Subject: [PATCH 009/361] Product model/controller --- app/controllers/api/products_controller.rb | 52 +++++++++++++++++++ app/models/product.rb | 3 ++ app/policies/product_policy.rb | 16 ++++++ app/services/product_service.rb | 8 +++ app/views/api/products/_product.json.jbuilder | 3 ++ app/views/api/products/create.json.jbuilder | 3 ++ app/views/api/products/index.json.jbuilder | 5 ++ app/views/api/products/show.json.jbuilder | 3 ++ app/views/api/products/update.json.jbuilder | 3 ++ config/routes.rb | 2 + db/migrate/20220712153708_create_products.rb | 19 +++++++ ...60137_create_join_table_product_machine.rb | 8 +++ db/schema.rb | 23 ++++++++ 13 files changed, 148 insertions(+) create mode 100644 app/controllers/api/products_controller.rb create mode 100644 app/models/product.rb create mode 100644 app/policies/product_policy.rb create mode 100644 app/services/product_service.rb create mode 100644 app/views/api/products/_product.json.jbuilder create mode 100644 app/views/api/products/create.json.jbuilder create mode 100644 app/views/api/products/index.json.jbuilder create mode 100644 app/views/api/products/show.json.jbuilder create mode 100644 app/views/api/products/update.json.jbuilder create mode 100644 db/migrate/20220712153708_create_products.rb create mode 100644 db/migrate/20220712160137_create_join_table_product_machine.rb diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb new file mode 100644 index 000000000..b48777b5c --- /dev/null +++ b/app/controllers/api/products_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# API Controller for resources of type Product +# 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] + + def index + @products = ProductService.list + end + + def show; end + + def create + authorize Product + @product = Product.new(product_params) + if @product.save + render status: :created + else + render json: @product.errors.full_messages, status: :unprocessable_entity + end + end + + def update + authorize @product + + if @product.update(product_params) + render status: :ok + else + render json: @product.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + authorize @product + @product.destroy + head :no_content + end + + private + + def set_product + @product = Product.find(params[:id]) + end + + def product_params + params.require(:product).permit(:name, :slug, :sku, :description, :is_active, + :product_category_id, :amount, :quantity_min, + :low_stock_alert, :low_stock_threshold) + end +end diff --git a/app/models/product.rb b/app/models/product.rb new file mode 100644 index 000000000..48d439822 --- /dev/null +++ b/app/models/product.rb @@ -0,0 +1,3 @@ +class Product < ApplicationRecord + belongs_to :product_category +end diff --git a/app/policies/product_policy.rb b/app/policies/product_policy.rb new file mode 100644 index 000000000..f64026b79 --- /dev/null +++ b/app/policies/product_policy.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Check the access policies for API::ProductsController +class ProductPolicy < ApplicationPolicy + def create? + user.privileged? + end + + def update? + user.privileged? + end + + def destroy? + user.privileged? + end +end diff --git a/app/services/product_service.rb b/app/services/product_service.rb new file mode 100644 index 000000000..d31f61ae8 --- /dev/null +++ b/app/services/product_service.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Provides methods for Product +class ProductService + def self.list + Product.all + end +end diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder new file mode 100644 index 000000000..b18ee0374 --- /dev/null +++ b/app/views/api/products/_product.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :amount, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold diff --git a/app/views/api/products/create.json.jbuilder b/app/views/api/products/create.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/create.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/app/views/api/products/index.json.jbuilder b/app/views/api/products/index.json.jbuilder new file mode 100644 index 000000000..bc58aeb30 --- /dev/null +++ b/app/views/api/products/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.array! @products do |product| + json.partial! 'api/products/product', product: product +end diff --git a/app/views/api/products/show.json.jbuilder b/app/views/api/products/show.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/app/views/api/products/update.json.jbuilder b/app/views/api/products/update.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/update.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/config/routes.rb b/config/routes.rb index 6abcf4dca..e9dc37f22 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -154,6 +154,8 @@ Rails.application.routes.draw do patch 'position', on: :member end + resources :products + # for admin resources :trainings do get :availabilities, on: :member diff --git a/db/migrate/20220712153708_create_products.rb b/db/migrate/20220712153708_create_products.rb new file mode 100644 index 000000000..154e1a896 --- /dev/null +++ b/db/migrate/20220712153708_create_products.rb @@ -0,0 +1,19 @@ +class CreateProducts < ActiveRecord::Migration[5.2] + def change + create_table :products do |t| + t.string :name + t.string :slug + t.string :sku + t.text :description + t.boolean :is_active, default: false + t.belongs_to :product_category, foreign_key: true + t.integer :amount + t.integer :quantity_min + t.jsonb :stock, default: { internal: 0, external: 0 } + t.boolean :low_stock_alert, default: false + t.integer :low_stock_threshold + + t.timestamps + end + end +end diff --git a/db/migrate/20220712160137_create_join_table_product_machine.rb b/db/migrate/20220712160137_create_join_table_product_machine.rb new file mode 100644 index 000000000..874005fd0 --- /dev/null +++ b/db/migrate/20220712160137_create_join_table_product_machine.rb @@ -0,0 +1,8 @@ +class CreateJoinTableProductMachine < ActiveRecord::Migration[5.2] + def change + create_join_table :products, :machines do |t| + # t.index [:product_id, :machine_id] + # t.index [:machine_id, :product_id] + end + end +end diff --git a/db/schema.rb b/db/schema.rb index e65091513..1222e9bcf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -367,6 +367,11 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.index ["machine_id"], name: "index_machines_availabilities_on_machine_id" end + create_table "machines_products", id: false, force: :cascade do |t| + t.bigint "product_id", null: false + t.bigint "machine_id", null: false + end + create_table "notifications", id: :serial, force: :cascade do |t| t.integer "receiver_id" t.string "attached_object_type" @@ -591,6 +596,23 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.index ["parent_id"], name: "index_product_categories_on_parent_id" end + create_table "products", force: :cascade do |t| + t.string "name" + t.string "slug" + t.string "sku" + t.text "description" + t.boolean "is_active", default: false + t.bigint "product_category_id" + t.integer "amount" + t.integer "quantity_min" + t.jsonb "stock", default: {"external"=>0, "internal"=>0} + t.boolean "low_stock_alert", default: false + t.integer "low_stock_threshold" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["product_category_id"], name: "index_products_on_product_category_id" + end + create_table "profile_custom_fields", force: :cascade do |t| t.string "label" t.boolean "required", default: false @@ -1112,6 +1134,7 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do add_foreign_key "prepaid_packs", "groups" add_foreign_key "prices", "groups" add_foreign_key "prices", "plans" + add_foreign_key "products", "product_categories" add_foreign_key "project_steps", "projects" add_foreign_key "project_users", "projects" add_foreign_key "project_users", "users" From e23e83000dbef3e0422975ca76464200719b04bb Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 13 Jul 2022 19:34:38 +0200 Subject: [PATCH 010/361] products page in front --- app/frontend/src/javascript/api/product.ts | 30 ++++++++ .../components/store/products-list.tsx | 50 ++++++++++++ .../javascript/components/store/products.tsx | 77 +++++++++++++++++++ .../src/javascript/controllers/admin/store.js | 30 +++++++- .../src/javascript/controllers/main_nav.js | 2 +- app/frontend/src/javascript/models/product.ts | 24 ++++++ app/frontend/src/javascript/router.js | 17 ++++ app/frontend/templates/admin/store/index.html | 8 +- .../templates/admin/store/products.html | 2 +- config/locales/app.admin.en.yml | 7 +- config/locales/app.admin.fr.yml | 7 +- 11 files changed, 244 insertions(+), 10 deletions(-) create mode 100644 app/frontend/src/javascript/api/product.ts create mode 100644 app/frontend/src/javascript/components/store/products-list.tsx create mode 100644 app/frontend/src/javascript/components/store/products.tsx create mode 100644 app/frontend/src/javascript/models/product.ts diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts new file mode 100644 index 000000000..edb434c95 --- /dev/null +++ b/app/frontend/src/javascript/api/product.ts @@ -0,0 +1,30 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { Product } from '../models/product'; + +export default class ProductAPI { + static async index (): Promise> { + const res: AxiosResponse> = await apiClient.get('/api/products'); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/products/${id}`); + return res?.data; + } + + static async create (product: Product): Promise { + const res: AxiosResponse = await apiClient.post('/api/products', { product }); + return res?.data; + } + + static async update (product: Product): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/products/${product.id}`, { product }); + 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/products-list.tsx b/app/frontend/src/javascript/components/store/products-list.tsx new file mode 100644 index 000000000..60f05cd96 --- /dev/null +++ b/app/frontend/src/javascript/components/store/products-list.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { FabButton } from '../base/fab-button'; +import { Product } from '../../models/product'; + +interface ProductsListProps { + products: Array, + onEdit: (product: Product) => void, + onDelete: (productId: number) => void, +} + +/** + * This component shows a list of all Products + */ +export const ProductsList: React.FC = ({ products, onEdit, onDelete }) => { + /** + * Init the process of editing the given product + */ + const editProduct = (product: Product): () => void => { + return (): void => { + onEdit(product); + }; + }; + + /** + * Init the process of delete the given product + */ + const deleteProduct = (productId: number): () => void => { + return (): void => { + onDelete(productId); + }; + }; + + return ( +
+ {products.map((product) => ( +
+ {product.name} +
+ + + + + + +
+
+ ))} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx new file mode 100644 index 000000000..09c7e0912 --- /dev/null +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -0,0 +1,77 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { HtmlTranslate } from '../base/html-translate'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { FabAlert } from '../base/fab-alert'; +import { FabButton } from '../base/fab-button'; +import { ProductsList } from './products-list'; +import { Product } from '../../models/product'; +import ProductAPI from '../../api/product'; + +declare const Application: IApplication; + +interface ProductsProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component shows all Products and filter + */ +const Products: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const [products, setProducts] = useState>([]); + const [product, setProduct] = useState(null); + + useEffect(() => { + ProductAPI.index().then(data => { + setProducts(data); + }); + }, []); + + /** + * Open edit the product modal + */ + const editProduct = (product: Product) => { + setProduct(product); + }; + + /** + * Delete a product + */ + const deleteProduct = async (productId: number): Promise => { + try { + await ProductAPI.destroy(productId); + const data = await ProductAPI.index(); + setProducts(data); + onSuccess(t('app.admin.store.products.successfully_deleted')); + } catch (e) { + onError(t('app.admin.store.products.unable_to_delete') + e); + } + }; + + return ( +
+

{t('app.admin.store.products.all_products')}

+ {t('app.admin.store.products.create_a_product')} + +
+ ); +}; + +const ProductsWrapper: React.FC = ({ onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/controllers/admin/store.js b/app/frontend/src/javascript/controllers/admin/store.js index 76cac12da..f46365752 100644 --- a/app/frontend/src/javascript/controllers/admin/store.js +++ b/app/frontend/src/javascript/controllers/admin/store.js @@ -4,9 +4,35 @@ */ 'use strict'; -Application.Controllers.controller('AdminStoreController', ['$scope', 'CSRF', 'growl', - function ($scope, CSRF, growl) { +Application.Controllers.controller('AdminStoreController', ['$scope', 'CSRF', 'growl', '$state', + function ($scope, CSRF, growl, $state) { + /* PRIVATE SCOPE */ + // Map of tab state and index + const TABS = { + 'app.admin.store.settings': 0, + 'app.admin.store.products': 1, + 'app.admin.store.categories': 2, + 'app.admin.store.orders': 3 + }; + /* PUBLIC SCOPE */ + // default tab: products + $scope.tabs = { + active: TABS[$state.current.name] + }; + + /** + * Callback triggered in click tab + */ + $scope.selectTab = () => { + setTimeout(function () { + const currentTab = _.keys(TABS)[$scope.tabs.active]; + if (currentTab !== $state.current.name) { + $state.go(currentTab, { location: true, notify: false, reload: false }); + } + }); + }; + /** * Callback triggered in case of error */ diff --git a/app/frontend/src/javascript/controllers/main_nav.js b/app/frontend/src/javascript/controllers/main_nav.js index c2a672e02..ead43fda0 100644 --- a/app/frontend/src/javascript/controllers/main_nav.js +++ b/app/frontend/src/javascript/controllers/main_nav.js @@ -84,7 +84,7 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm authorizedRoles: ['admin', 'manager'] }, { - state: 'app.admin.store', + state: 'app.admin.store.products', linkText: 'app.public.common.manage_the_store', linkIcon: 'cart-plus', authorizedRoles: ['admin', 'manager'] diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts new file mode 100644 index 000000000..b5818c1f4 --- /dev/null +++ b/app/frontend/src/javascript/models/product.ts @@ -0,0 +1,24 @@ +export enum StockType { + internal = 'internal', + external = 'external' +} + +export interface Stock { + internal: number, + external: number, +} + +export interface Product { + id: number, + name: string, + slug: string, + sku: string, + description: string, + is_active: boolean, + product_category_id: number, + amount: number, + quantity_min: number, + stock: Stock, + low_stock_alert: boolean, + low_stock_threshold: number, +} diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index d86e624a6..da074335a 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -1105,6 +1105,7 @@ angular.module('application.router', ['ui.router']) }) .state('app.admin.store', { + abstract: true, url: '/admin/store', views: { 'main@': { @@ -1114,6 +1115,22 @@ angular.module('application.router', ['ui.router']) } }) + .state('app.admin.store.settings', { + url: '/settings' + }) + + .state('app.admin.store.products', { + url: '/products' + }) + + .state('app.admin.store.categories', { + url: '/categories' + }) + + .state('app.admin.store.orders', { + url: '/orders' + }) + // OpenAPI Clients .state('app.admin.open_api_clients', { url: '/open_api_clients', diff --git a/app/frontend/templates/admin/store/index.html b/app/frontend/templates/admin/store/index.html index cf591b0ec..0b7557e96 100644 --- a/app/frontend/templates/admin/store/index.html +++ b/app/frontend/templates/admin/store/index.html @@ -20,19 +20,19 @@
- + - + - + - + diff --git a/app/frontend/templates/admin/store/products.html b/app/frontend/templates/admin/store/products.html index c4db68bf6..e37bcce4f 100644 --- a/app/frontend/templates/admin/store/products.html +++ b/app/frontend/templates/admin/store/products.html @@ -1 +1 @@ -

Products page

+ diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index d9c24536e..dede5aae3 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1925,4 +1925,9 @@ en: success: "The category has been successfully deleted" save: "Save" required: "This field is required" - slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" \ No newline at end of file + slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + products: + all_products: "All products" + create_a_product: "Create a product" + successfully_deleted: "The product has been successfully deleted" + unable_to_delete: "Unable to delete the product: " diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 2bbcea278..991a429e4 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1925,4 +1925,9 @@ fr: success: "La catégorie a bien été supprimée" save: "Enregistrer" required: "This field is required" - slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" \ No newline at end of file + slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + products: + all_products: "Tous les produits" + create_a_product: "Créer un produit" + successfully_deleted: "Le produit a bien été supprimé" + unable_to_delete: "Impossible de supprimer le produit: " From 5e1436eda4a7ad2a39b9a2c056a828e3e7380333 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Fri, 22 Jul 2022 18:48:28 +0200 Subject: [PATCH 011/361] create/edit product form --- app/controllers/api/products_controller.rb | 11 +- .../components/form/form-check-list.tsx | 99 +++++++++ .../components/store/edit-product.tsx | 56 +++++ .../components/store/new-product.tsx | 58 +++++ .../components/store/product-form.tsx | 199 ++++++++++++++++++ .../javascript/components/store/products.tsx | 16 +- .../controllers/admin/store_products.js | 47 +++++ app/frontend/src/javascript/models/product.ts | 9 +- app/frontend/src/javascript/router.js | 52 ++++- app/frontend/src/stylesheets/application.scss | 1 + .../modules/form/form-check-list.scss | 17 ++ .../templates/admin/store/product_edit.html | 35 +++ .../templates/admin/store/product_new.html | 35 +++ app/models/machine.rb | 1 + app/models/product.rb | 7 + app/views/api/products/_product.json.jbuilder | 3 +- config/locales/app.admin.en.yml | 23 ++ config/locales/app.admin.fr.yml | 23 ++ config/locales/app.shared.en.yml | 2 + config/locales/app.shared.fr.yml | 2 + db/migrate/20220712153708_create_products.rb | 2 + 21 files changed, 677 insertions(+), 21 deletions(-) create mode 100644 app/frontend/src/javascript/components/form/form-check-list.tsx create mode 100644 app/frontend/src/javascript/components/store/edit-product.tsx create mode 100644 app/frontend/src/javascript/components/store/new-product.tsx create mode 100644 app/frontend/src/javascript/components/store/product-form.tsx create mode 100644 app/frontend/src/javascript/controllers/admin/store_products.js create mode 100644 app/frontend/src/stylesheets/modules/form/form-check-list.scss create mode 100644 app/frontend/templates/admin/store/product_edit.html create mode 100644 app/frontend/templates/admin/store/product_new.html diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index b48777b5c..e411ce090 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[show update destroy] def index @products = ProductService.list @@ -15,6 +15,8 @@ class API::ProductsController < API::ApiController def create authorize Product @product = Product.new(product_params) + @product.amount = nil if @product.amount.zero? + @product.amount *= 100 if @product.amount.present? if @product.save render status: :created else @@ -25,7 +27,10 @@ class API::ProductsController < API::ApiController def update authorize @product - if @product.update(product_params) + product_parameters = product_params + product_parameters[:amount] = nil if product_parameters[:amount].zero? + product_parameters[:amount] = product_parameters[:amount] * 100 if product_parameters[:amount].present? + if @product.update(product_parameters) render status: :ok else render json: @product.errors.full_messages, status: :unprocessable_entity @@ -47,6 +52,6 @@ class API::ProductsController < API::ApiController def product_params params.require(:product).permit(:name, :slug, :sku, :description, :is_active, :product_category_id, :amount, :quantity_min, - :low_stock_alert, :low_stock_threshold) + :low_stock_alert, :low_stock_threshold, machine_ids: []) end end diff --git a/app/frontend/src/javascript/components/form/form-check-list.tsx b/app/frontend/src/javascript/components/form/form-check-list.tsx new file mode 100644 index 000000000..1299dcd56 --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-check-list.tsx @@ -0,0 +1,99 @@ +import React, { BaseSyntheticEvent } from 'react'; +import { Controller, Path, FieldPathValue } from 'react-hook-form'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FieldPath } from 'react-hook-form/dist/types/path'; +import { useTranslation } from 'react-i18next'; +import { UnpackNestedValue } from 'react-hook-form/dist/types'; +import { FormControlledComponent } from '../../models/form-component'; +import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item'; +import { FabButton } from '../base/fab-button'; + +/** + * Checklist Option format + */ +export type ChecklistOption = { value: TOptionValue, label: string }; + +interface FormCheckListProps extends FormControlledComponent, AbstractFormItemProps { + defaultValue?: Array, + options: Array>, + onChange?: (values: Array) => void, +} + +/** + * This component is a template for an check list component to use within React Hook Form + */ +export const FormCheckList = ({ id, control, label, tooltip, defaultValue, className, rules, disabled, error, warning, formState, onChange, options }: FormCheckListProps) => { + const { t } = useTranslation('shared'); + + /** + * Verify if the provided option is currently ticked + */ + const isChecked = (values: Array, option: ChecklistOption): boolean => { + return !!values?.includes(option.value); + }; + + /** + * Callback triggered when a checkbox is ticked or unticked. + */ + const toggleCheckbox = (option: ChecklistOption, values: Array = [], cb: (value: Array) => void) => { + return (event: BaseSyntheticEvent) => { + let newValues: Array = []; + if (event.target.checked) { + newValues = values.concat(option.value); + } else { + newValues = values.filter(v => v !== option.value); + } + cb(newValues); + if (typeof onChange === 'function') { + onChange(newValues); + } + }; + }; + + /** + * Callback triggered to select all options + */ + const allSelect = (cb: (value: Array) => void) => { + return () => { + const newValues: Array = options.map(o => o.value); + cb(newValues); + if (typeof onChange === 'function') { + onChange(newValues); + } + }; + }; + + // Compose classnames from props + const classNames = [ + `${className || ''}` + ].join(' '); + + return ( + + } + control={control} + defaultValue={defaultValue as UnpackNestedValue>>} + rules={rules} + render={({ field: { onChange, value } }) => { + return ( + <> +
+ {options.map((option, k) => { + return ( +
+ + +
+ ); + })} +
+ {t('app.shared.form_check_list.select_all')} + + ); + }} /> +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/edit-product.tsx b/app/frontend/src/javascript/components/store/edit-product.tsx new file mode 100644 index 000000000..62ccad66c --- /dev/null +++ b/app/frontend/src/javascript/components/store/edit-product.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { ProductForm } from './product-form'; +import { Product } from '../../models/product'; +import ProductAPI from '../../api/product'; + +declare const Application: IApplication; + +interface EditProductProps { + productId: number, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component show new product form + */ +const EditProduct: React.FC = ({ productId, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const [product, setProduct] = useState(); + + useEffect(() => { + ProductAPI.get(productId).then(data => { + setProduct(data); + }).catch(onError); + }, []); + + /** + * Success to save product and return to product list + */ + const saveProductSuccess = () => { + onSuccess(t('app.admin.store.edit_product.successfully_updated')); + window.location.href = '/#!/admin/store/products'; + }; + + if (product) { + return ( + + ); + } + return null; +}; + +const EditProductWrapper: React.FC = ({ productId, onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/new-product.tsx b/app/frontend/src/javascript/components/store/new-product.tsx new file mode 100644 index 000000000..e3f2ca4d2 --- /dev/null +++ b/app/frontend/src/javascript/components/store/new-product.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { ProductForm } from './product-form'; + +declare const Application: IApplication; + +interface NewProductProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component show new product form + */ +const NewProduct: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const product = { + id: undefined, + name: '', + slug: '', + sku: '', + description: '', + is_active: false, + quantity_min: 1, + stock: { + internal: 0, + external: 0 + }, + low_stock_alert: false, + machine_ids: [] + }; + + /** + * Success to save product and return to product list + */ + const saveProductSuccess = () => { + onSuccess(t('app.admin.store.new_product.successfully_created')); + window.location.href = '/#!/admin/store/products'; + }; + + return ( + + ); +}; + +const NewProductWrapper: React.FC = ({ onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx new file mode 100644 index 000000000..e4198bbd5 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -0,0 +1,199 @@ +import React, { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import slugify from 'slugify'; +import _ from 'lodash'; +import { HtmlTranslate } from '../base/html-translate'; +import { Product } from '../../models/product'; +import { FormInput } from '../form/form-input'; +import { FormSwitch } from '../form/form-switch'; +import { FormSelect } from '../form/form-select'; +import { FormCheckList } from '../form/form-check-list'; +import { FormRichText } from '../form/form-rich-text'; +import { FabButton } from '../base/fab-button'; +import { FabAlert } from '../base/fab-alert'; +import ProductCategoryAPI from '../../api/product-category'; +import MachineAPI from '../../api/machine'; +import ProductAPI from '../../api/product'; + +interface ProductFormProps { + product: Product, + title: string, + onSuccess: (product: Product) => void, + onError: (message: string) => void, +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +type selectOption = { value: number, label: string }; + +/** + * Option format, expected by checklist + */ +type checklistOption = { value: number, label: string }; + +/** + * Form component to create or update a product + */ +export const ProductForm: React.FC = ({ product, title, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const { handleSubmit, register, control, formState, setValue, reset } = useForm({ defaultValues: { ...product } }); + const [isActivePrice, setIsActivePrice] = useState(product.id && _.isFinite(product.amount) && product.amount > 0); + const [productCategories, setProductCategories] = useState([]); + const [machines, setMachines] = useState([]); + + useEffect(() => { + ProductCategoryAPI.index().then(data => { + setProductCategories(buildSelectOptions(data)); + }).catch(onError); + MachineAPI.index({ disabled: false }).then(data => { + setMachines(buildChecklistOptions(data)); + }).catch(onError); + }, []); + + /** + * Convert the provided array of items to the react-select format + */ + const buildSelectOptions = (items: Array<{ id?: number, name: string }>): Array => { + return items.map(t => { + return { value: t.id, label: t.name }; + }); + }; + + /** + * Convert the provided array of items to the checklist format + */ + const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array => { + return items.map(t => { + return { value: t.id, label: t.name }; + }); + }; + + /** + * Callback triggered when the name has changed. + */ + const handleNameChange = (event: React.ChangeEvent): void => { + const name = event.target.value; + const slug = slugify(name, { lower: true, strict: true }); + setValue('slug', slug); + }; + + /** + * Callback triggered when is active price has changed. + */ + const toggleIsActivePrice = (value: boolean) => { + if (!value) { + setValue('amount', null); + } + setIsActivePrice(value); + }; + + /** + * Callback triggered when the form is submitted: process with the product creation or update. + */ + const onSubmit = (event: React.FormEvent) => { + return handleSubmit((data: Product) => { + saveProduct(data); + })(event); + }; + + /** + * Call product creation or update api + */ + const saveProduct = (data: Product) => { + if (product.id) { + ProductAPI.update(data).then((res) => { + reset(res); + onSuccess(res); + }).catch(onError); + } else { + ProductAPI.create(data).then((res) => { + reset(res); + onSuccess(res); + }).catch(onError); + } + }; + + return ( + <> +

{title}

+ {t('app.admin.store.product_form.save')} +
+ + + + +
+

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

+ + {isActivePrice &&
+ + +
} +

{t('app.admin.store.product_form.assigning_category')}

+ + + + +

{t('app.admin.store.product_form.assigning_machines')}

+ + + + +

{t('app.admin.store.product_form.product_description')}

+ + + + +
+
+ {t('app.admin.store.product_form.save')} +
+ + + ); +}; diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 09c7e0912..7f60082a3 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -1,10 +1,8 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; -import { HtmlTranslate } from '../base/html-translate'; import { Loader } from '../base/loader'; import { IApplication } from '../../models/application'; -import { FabAlert } from '../base/fab-alert'; import { FabButton } from '../base/fab-button'; import { ProductsList } from './products-list'; import { Product } from '../../models/product'; @@ -24,7 +22,6 @@ const Products: React.FC = ({ onSuccess, onError }) => { const { t } = useTranslation('admin'); const [products, setProducts] = useState>([]); - const [product, setProduct] = useState(null); useEffect(() => { ProductAPI.index().then(data => { @@ -33,10 +30,10 @@ const Products: React.FC = ({ onSuccess, onError }) => { }, []); /** - * Open edit the product modal + * Goto edit product page */ const editProduct = (product: Product) => { - setProduct(product); + window.location.href = `/#!/admin/store/products/${product.id}/edit`; }; /** @@ -53,10 +50,17 @@ const Products: React.FC = ({ onSuccess, onError }) => { } }; + /** + * Goto new product page + */ + const newProduct = (): void => { + window.location.href = '/#!/admin/store/products/new'; + }; + return (

{t('app.admin.store.products.all_products')}

- {t('app.admin.store.products.create_a_product')} + {t('app.admin.store.products.create_a_product')} { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /** + * Click Callback triggered in case of back products list + */ + $scope.backProductsList = () => { + $state.go('app.admin.store.products'); + }; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index b5818c1f4..9038dbd5c 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -15,10 +15,11 @@ export interface Product { sku: string, description: string, is_active: boolean, - product_category_id: number, - amount: number, - quantity_min: number, + product_category_id?: number, + amount?: number, + quantity_min?: number, stock: Stock, low_stock_alert: boolean, - low_stock_threshold: number, + low_stock_threshold?: number, + machine_ids: number[], } diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index da074335a..df21a17b5 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -1106,7 +1106,11 @@ angular.module('application.router', ['ui.router']) .state('app.admin.store', { abstract: true, - url: '/admin/store', + url: '/admin/store' + }) + + .state('app.admin.store.settings', { + url: '/settings', views: { 'main@': { templateUrl: '/admin/store/index.html', @@ -1115,20 +1119,54 @@ angular.module('application.router', ['ui.router']) } }) - .state('app.admin.store.settings', { - url: '/settings' + .state('app.admin.store.products', { + url: '/products', + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } }) - .state('app.admin.store.products', { - url: '/products' + .state('app.admin.store.products_new', { + url: '/products/new', + views: { + 'main@': { + templateUrl: '/admin/store/product_new.html', + controller: 'AdminStoreProductController' + } + } + }) + + .state('app.admin.store.products_edit', { + url: '/products/:id/edit', + views: { + 'main@': { + templateUrl: '/admin/store/product_edit.html', + controller: 'AdminStoreProductController' + } + } }) .state('app.admin.store.categories', { - url: '/categories' + url: '/categories', + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } }) .state('app.admin.store.orders', { - url: '/orders' + url: '/orders', + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } }) // OpenAPI Clients diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 48fe7e9a3..b3543e447 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -38,6 +38,7 @@ @import "modules/form/form-input"; @import "modules/form/form-rich-text"; @import "modules/form/form-switch"; +@import "modules/form/form-check-list"; @import "modules/group/change-group"; @import "modules/machines/machine-card"; @import "modules/machines/machines-filters"; diff --git a/app/frontend/src/stylesheets/modules/form/form-check-list.scss b/app/frontend/src/stylesheets/modules/form/form-check-list.scss new file mode 100644 index 000000000..f2b255c7d --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-check-list.scss @@ -0,0 +1,17 @@ +.form-check-list { + position: relative; + + .form-item-field { + display: block !important; + } + + .checklist { + display: flex; + padding: 16px; + flex-wrap: wrap; + } + + .checklist-item { + flex: 0 0 33.333333%; + } +} diff --git a/app/frontend/templates/admin/store/product_edit.html b/app/frontend/templates/admin/store/product_edit.html new file mode 100644 index 000000000..0bdf21ca9 --- /dev/null +++ b/app/frontend/templates/admin/store/product_edit.html @@ -0,0 +1,35 @@ +
+
+
+
+ +
+
+
+
+

{{ 'app.admin.store.manage_the_store' }}

+
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+
diff --git a/app/frontend/templates/admin/store/product_new.html b/app/frontend/templates/admin/store/product_new.html new file mode 100644 index 000000000..eb61f3019 --- /dev/null +++ b/app/frontend/templates/admin/store/product_new.html @@ -0,0 +1,35 @@ +
+
+
+
+ +
+
+
+
+

{{ 'app.admin.store.manage_the_store' }}

+
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+
diff --git a/app/models/machine.rb b/app/models/machine.rb index e7d0eadde..da118eddf 100644 --- a/app/models/machine.rb +++ b/app/models/machine.rb @@ -29,6 +29,7 @@ class Machine < ApplicationRecord has_one :payment_gateway_object, as: :item + has_and_belongs_to_many :products after_create :create_statistic_subtype after_create :create_machine_prices diff --git a/app/models/product.rb b/app/models/product.rb index 48d439822..29d0e1697 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -1,3 +1,10 @@ +# frozen_string_literal: true + +# Product is a model for the merchandise hold information of product in store class Product < ApplicationRecord belongs_to :product_category + + has_and_belongs_to_many :machines + + validates_numericality_of :amount, greater_than: 0, allow_nil: true end diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder index b18ee0374..624f8e45d 100644 --- a/app/views/api/products/_product.json.jbuilder +++ b/app/views/api/products/_product.json.jbuilder @@ -1,3 +1,4 @@ # frozen_string_literal: true -json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :amount, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold +json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold, :machine_ids +json.amount product.amount / 100.0 if product.amount.present? diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index dede5aae3..95d249d95 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1899,6 +1899,7 @@ en: all_products: "All products" categories_of_store: "Store's categories" the_orders: "Orders" + back_products_list: "Back to products list" product_categories: title: "Categories" info: "Information:
Find below all the categories created. The categories are arranged on two levels maximum, you can arrange them with a drag and drop. The order of the categories will be identical on the public view and the list below. Please note that you can delete a category or a sub-category even if they are associated with products. The latter will be left without categories. If you delete a category that contains sub-categories, the latter will also be deleted. Make sure that your categories are well arranged and save your choice." @@ -1931,3 +1932,25 @@ en: create_a_product: "Create a product" successfully_deleted: "The product has been successfully deleted" unable_to_delete: "Unable to delete the product: " + new_product: + add_a_new_product: "Add a new product" + successfully_created: "The new product has been created." + edit_product: + successfully_updated: "The product has been updated." + product_form: + name: "Name of product" + sku: "Reference product (SKU)" + slug: "Name of URL" + is_show_in_store: "Available in the store" + is_active_price: "Activate the price" + price_and_rule_of_selling_product: "Price and rule for selling the product" + price: "Price of product" + quantity_min: "Minimum number of items for the shopping cart" + linking_product_to_category: "Linking this product to an existing category" + assigning_category: "Assigning a category" + assigning_category_info: "Information
You can only declare one category per product. If you assign this product to a sub-category, it will automatically be assigned to its parent category as well." + assigning_machines: "Assigning machines" + assigning_machines_info: "Information
You can link one or more machines from your fablab to your product, this product will then be subject to the filters on the catalogue view.
The machines selected below will be linked to the product." + product_description: "Product description" + product_description_info: "Information
This product description will be present in the product sheet. You have a few editorial styles at your disposal to create the product sheet." + save: "Save" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 991a429e4..fabc04045 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1899,6 +1899,7 @@ fr: all_products: "Tous les produits" categories_of_store: "Les catégories de la boutique" the_orders: "Les commandes" + back_products_list: "Retrounez à la liste" product_categories: title: "Les catégories" info: "Information:
Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveaux maximum, vous pouvez les agencer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. Veillez au bon agencement de vos catégories et sauvegarder votre choix." @@ -1931,3 +1932,25 @@ fr: create_a_product: "Créer un produit" successfully_deleted: "Le produit a bien été supprimé" unable_to_delete: "Impossible de supprimer le produit: " + new_product: + add_a_new_product: "Ajouter un nouveau produit" + successfully_created: "Le produit a bien été créée." + edit_product: + successfully_updated: "Le produit a bien été mise à jour." + product_form: + name: "Nom de produit" + sku: "Référence produit (SKU)" + slug: "Nom de l'URL" + is_show_in_store: "Visible dans la boutique" + is_active_price: "Activer le prix" + price_and_rule_of_selling_product: "Prix et règle de vente du produit" + price: "Prix du produit" + quantity_min: "Nombre d'article minimum pour la mise au panier" + linking_product_to_category: "Lier ce product à une catégorie exisante" + assigning_category: "Attribuer à une catégorie" + assigning_category_info: "Information
Vous ne pouvez déclarer qu'une catégorie par produit. Si vous attribuez ce produit à une sous catégorie, il sera attribué automatiquement aussi à sa catégorie parent." + assigning_machines: "Attribuer aux machines" + assigning_machines_info: "Information
Vous pouvez lier une ou plusieurs machines de votre fablab à votre produit, Ce produit sera alors assujetti aux filtres sur la vue catalogue.
Les machines sélectionnées ci-dessous seront liées au produit." + product_description: "Description du produit" + product_description_info: "Information
Cette description du produit sera présente dans la fiche du produit. Vous avez à disposition quelques styles rédactionnels pour créer la fiche du produit." + save: "Enregistrer" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 34724b6d0..14ff7ba51 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -550,3 +550,5 @@ en: validate_button: "Validate the new card" form_multi_select: create_label: "Add {VALUE}" + form_check_list: + select_all: "Select all" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index d39a36486..0827df858 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -550,3 +550,5 @@ fr: validate_button: "Valider la nouvelle carte" form_multi_select: create_label: "Ajouter {VALUE}" + form_check_list: + select_all: "Tout sélectionner" diff --git a/db/migrate/20220712153708_create_products.rb b/db/migrate/20220712153708_create_products.rb index 154e1a896..3876ca037 100644 --- a/db/migrate/20220712153708_create_products.rb +++ b/db/migrate/20220712153708_create_products.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateProducts < ActiveRecord::Migration[5.2] def change create_table :products do |t| From 00f811645a10d57d7d95586b04ca98e9306f045f Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 25 Jul 2022 10:26:01 +0200 Subject: [PATCH 012/361] add products relation in product's category --- app/models/product.rb | 2 ++ app/models/product_category.rb | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/models/product.rb b/app/models/product.rb index 29d0e1697..d4c26abc2 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -7,4 +7,6 @@ class Product < ApplicationRecord has_and_belongs_to_many :machines validates_numericality_of :amount, greater_than: 0, allow_nil: true + + scope :active, -> { where(is_active: true) } end diff --git a/app/models/product_category.rb b/app/models/product_category.rb index 8feb8afda..fdc492f83 100644 --- a/app/models/product_category.rb +++ b/app/models/product_category.rb @@ -8,5 +8,7 @@ class ProductCategory < ApplicationRecord belongs_to :parent, class_name: 'ProductCategory' has_many :children, class_name: 'ProductCategory', foreign_key: :parent_id + has_many :products + acts_as_list scope: :parent, top_of_list: 0 end From d690db8b6f9c3dce3efc2e0398b28d7b6ad853c6 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 25 Jul 2022 11:16:41 +0200 Subject: [PATCH 013/361] reset product_category_id to nil if product_category is removed --- app/services/product_category_service.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/services/product_category_service.rb b/app/services/product_category_service.rb index 4c4c2ed67..fbcc72cf8 100644 --- a/app/services/product_category_service.rb +++ b/app/services/product_category_service.rb @@ -7,7 +7,13 @@ class ProductCategoryService end def self.destroy(product_category) - ProductCategory.where(parent_id: product_category.id).destroy_all - product_category.destroy + ActiveRecord::Base.transaction do + sub_categories = ProductCategory.where(parent_id: product_category.id) + # remove product_category and sub-categories related id in product + Product.where(product_category_id: sub_categories.map(&:id).push(product_category.id)).update(product_category_id: nil) + # remove all sub-categories + sub_categories.destroy_all + product_category.destroy + end end end From a53c68d6b49fd0e8ba44fcf785ea472bc6d21f63 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 25 Jul 2022 16:29:01 +0200 Subject: [PATCH 014/361] update edit product comment --- app/frontend/src/javascript/components/store/edit-product.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend/src/javascript/components/store/edit-product.tsx b/app/frontend/src/javascript/components/store/edit-product.tsx index 62ccad66c..384f4d6a0 100644 --- a/app/frontend/src/javascript/components/store/edit-product.tsx +++ b/app/frontend/src/javascript/components/store/edit-product.tsx @@ -16,7 +16,7 @@ interface EditProductProps { } /** - * This component show new product form + * This component show edit product form */ const EditProduct: React.FC = ({ productId, onSuccess, onError }) => { const { t } = useTranslation('admin'); From 5b65fb7a54d1f3c83f952d43ca99cf1603f0e837 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 25 Jul 2022 19:42:24 +0200 Subject: [PATCH 015/361] Remove react-beautiful-dnd --- .../categories/product-categories-item.tsx | 41 +++++++++++++++++++ .../categories/product-category-form.tsx | 11 ++++- config/locales/app.admin.en.yml | 1 + 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 app/frontend/src/javascript/components/store/categories/product-categories-item.tsx diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx new file mode 100644 index 000000000..1615df658 --- /dev/null +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { ProductCategory } from '../../../models/product-category'; +import { ManageProductCategory } from './manage-product-category'; +import { FabButton } from '../../base/fab-button'; +import { DotsSixVertical } from 'phosphor-react'; + +interface ProductCategoriesItemProps { + productCategories: Array, + category: ProductCategory, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * Renders a draggable category item + */ +export const ProductCategoriesItem: React.FC = ({ productCategories, category, onSuccess, onError }) => { + return ( +
+
+

{category.name}

+ [count] +
+
+
+ + +
+
+ } className='draghandle' /> +
+
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/categories/product-category-form.tsx b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx index 87136b03e..2729d966e 100644 --- a/app/frontend/src/javascript/components/store/categories/product-category-form.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx @@ -32,15 +32,22 @@ export const ProductCategoryForm: React.FC = ({ action const { register, watch, setValue, control, handleSubmit, formState } = useForm({ defaultValues: { ...productCategory } }); // filter all first level product categorie - const parents = productCategories.filter(c => !c.parent_id); + let parents = productCategories.filter(c => !c.parent_id); + if (action === 'update') { + parents = parents.filter(c => c.id !== productCategory.id); + } /** * Convert all parents to the react-select format */ const buildOptions = (): Array => { - return parents.map(t => { + const options = parents.map(t => { return { value: t.id, label: t.name }; }); + if (action === 'update') { + options.unshift({ value: null, label: t('app.admin.store.product_category_form.no_parent') }); + } + return options; }; // Create slug from category's name diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 95d249d95..103e8af33 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1914,6 +1914,7 @@ en: name: "Name of category" slug: "Name of URL" select_parent_product_category: "Choose a parent category (N1)" + no_parent: "No parent" create: error: "Unable to create the category: " success: "The new category has been created." From 57ef555943b9541450f0bc5286d2affefa7d1451 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 28 Jul 2022 15:20:25 +0200 Subject: [PATCH 016/361] Temporary broken drag and drop --- .../categories/product-categories-item.tsx | 25 ++- .../categories/product-categories-tree.tsx | 205 +++++++++++++++--- .../store/categories/product-categories.tsx | 39 +++- .../modules/store/product-categories.scss | 16 +- package.json | 2 + yarn.lock | 36 +++ 6 files changed, 286 insertions(+), 37 deletions(-) diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx index 1615df658..9799eed14 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -1,12 +1,14 @@ import React from 'react'; import { ProductCategory } from '../../../models/product-category'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { ManageProductCategory } from './manage-product-category'; -import { FabButton } from '../../base/fab-button'; import { DotsSixVertical } from 'phosphor-react'; interface ProductCategoriesItemProps { productCategories: Array, category: ProductCategory, + isChild?: boolean, onSuccess: (message: string) => void, onError: (message: string) => void, } @@ -14,9 +16,22 @@ interface ProductCategoriesItemProps { /** * Renders a draggable category item */ -export const ProductCategoriesItem: React.FC = ({ productCategories, category, onSuccess, onError }) => { +export const ProductCategoriesItem: React.FC = ({ productCategories, category, isChild, onSuccess, onError }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition + } = useSortable({ id: category.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition + }; + return ( -
+

{category.name}

[count] @@ -33,7 +48,9 @@ export const ProductCategoriesItem: React.FC = ({ pr onSuccess={onSuccess} onError={onError} />
- } className='draghandle' /> +
diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx index 132a2328c..01b8797af 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx @@ -1,11 +1,14 @@ -import React from 'react'; +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React, { useEffect, useState } from 'react'; +import { useImmer } from 'use-immer'; import { ProductCategory } from '../../../models/product-category'; -import { DotsSixVertical } from 'phosphor-react'; -import { FabButton } from '../../base/fab-button'; -import { ManageProductCategory } from './manage-product-category'; +import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, DragMoveEvent } from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { ProductCategoriesItem } from './product-categories-item'; interface ProductCategoriesTreeProps { productCategories: Array, + onDnd: (list: Array) => void, onSuccess: (message: string) => void, onError: (message: string) => void, } @@ -13,30 +16,180 @@ interface ProductCategoriesTreeProps { /** * This component shows a tree list of all Product's Categories */ -export const ProductCategoriesTree: React.FC = ({ productCategories, onSuccess, onError }) => { +export const ProductCategoriesTree: React.FC = ({ productCategories, onDnd, onSuccess, onError }) => { + const [categoriesList, setCategoriesList] = useImmer(productCategories); + const [hiddenChildren, setHiddenChildren] = useState({}); + + // Initialize state from props, sorting list as a tree + useEffect(() => { + setCategoriesList(productCategories); + }, [productCategories]); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); + + /** + * On drag start + */ + const handleDragStart = ({ active }: DragMoveEvent) => { + hideChildren(active.id, categoriesList.findIndex(el => el.id === active.id)); + const activeChildren = categoriesList.filter(c => c.parent_id === active.id); + if (activeChildren.length) { + setHiddenChildren({ [active.id]: activeChildren }); + const activeIndex = categoriesList.findIndex(el => el.id === active.id); + const tmpList = [...categoriesList]; + tmpList.splice(activeIndex + 1, activeChildren.length); + setCategoriesList(tmpList); + } + }; + + /** + * On drag move + */ + const handleDragMove = ({ delta, over }: DragMoveEvent) => { + console.log(findCategory(over.id).name); + if (delta.x > 48) { + console.log('Child'); + } else { + console.log('Parent'); + } + }; + + /** + * Update categories list after an item was dropped + */ + + const handleDragEnd = ({ active, over }: DragMoveEvent) => { + let newOrder = [...categoriesList]; + + // si déplacé sur une autre catégorie… + if (active.id !== over.id) { + // liste d'ids des catégories visibles + const previousIdsOrder = over?.data.current.sortable.items; + // index dans previousIdsOrder de la catégorie déplacée + const oldIndex = active.data.current.sortable.index; + // index dans previousIdsOrder de la catégorie de réception + const newIndex = over.data.current.sortable.index; + // liste de catégories mise à jour après le drop + const newIdsOrder = arrayMove(previousIdsOrder, oldIndex, newIndex); + // id du parent de la catégorie de réception + const newParentId = categoriesList[newIndex].parent_id; + + // nouvelle liste de catégories classées par newIdsOrder + newOrder = newIdsOrder.map(sortedId => { + // catégorie courante du map retrouvée grâce à l'id + const categoryFromId = findCategory(sortedId); + // si catégorie courante = catégorie déplacée… + if (categoryFromId.id === active.id) { + // maj du parent + categoryFromId.parent_id = newParentId; + } + // retour de la catégorie courante + return categoryFromId; + }); + } + // insert siblings back + if (hiddenChildren[active.id]?.length) { + newOrder.splice(over.data.current.sortable.index + 1, 0, ...hiddenChildren[active.id]); + setHiddenChildren({ ...hiddenChildren, [active.id]: null }); + } + onDnd(newOrder); + }; + + /** + * Reset state if the drag was canceled + */ + const handleDragCancel = ({ active }: DragMoveEvent) => { + setHiddenChildren({ ...hiddenChildren, [active.id]: null }); + setCategoriesList(productCategories); + }; + + /** + * Hide children by their parent's id + */ + const hideChildren = (parentId, parentIndex) => { + const children = findChildren(parentId); + if (children?.length) { + const tmpList = [...categoriesList]; + tmpList.splice(parentIndex + 1, children.length); + setCategoriesList(tmpList); + } + }; + + /** + * Find a category by its id + */ + const findCategory = (id) => { + return categoriesList.find(c => c.id === id); + }; + /** + * Find the children categories of a parent category by its id + */ + const findChildren = (id) => { + const displayedChildren = categoriesList.filter(c => c.parent_id === id); + if (displayedChildren.length) { + return displayedChildren; + } + return hiddenChildren[id]; + }; + /** + * Find category's status by its id + * single | parent | child + */ + const categoryStatus = (id) => { + const c = findCategory(id); + if (!c.parent_id) { + if (findChildren(id)?.length) { + return 'parent'; + } + return 'single'; + } else { + return 'child'; + } + }; + + /** + * Translate visual order into categories data positions + */ + const indexToPosition = (sortedIds: number[]) => { + const sort = sortedIds.map(sortedId => categoriesList.find(el => el.id === sortedId)); + const newPositions = sort.map(c => { + if (typeof c.parent_id === 'number') { + const parentIndex = sort.findIndex(el => el.id === c.parent_id); + const currentIndex = sort.findIndex(el => el.id === c.id); + return { ...c, position: (currentIndex - parentIndex - 1) }; + } + return c; + }); + return newPositions; + }; + return ( -
- {productCategories.map((category) => ( -
-
-

{category.name}

- [count] -
-
-
- + +
+ {categoriesList + .map((category) => ( + - -
- } className='draghandle' /> -
+ category={category} + onSuccess={onSuccess} + onError={onError} + isChild={typeof category.parent_id === 'number'} + /> + ))}
- ))} -
+ + ); }; diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx index 0900f56fa..e7cae432f 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -5,6 +5,7 @@ import ProductCategoryAPI from '../../../api/product-category'; import { ManageProductCategory } from './manage-product-category'; import { ProductCategoriesTree } from './product-categories-tree'; import { FabAlert } from '../../base/fab-alert'; +import { FabButton } from '../../base/fab-button'; import { HtmlTranslate } from '../../base/html-translate'; import { IApplication } from '../../../models/application'; import { Loader } from '../../base/loader'; @@ -41,28 +42,58 @@ const ProductCategories: React.FC = ({ onSuccess, onErro refreshCategories(); }; + /** + * Update state after drop + */ + const handleDnd = (data: ProductCategory[]) => { + setProductCategories(data); + }; + /** * Refresh the list of categories */ const refreshCategories = () => { ProductCategoryAPI.index().then(data => { - setProductCategories(data); + // Translate ProductCategory.position to array index + const sortedCategories = data + .filter(c => !c.parent_id) + .sort((a, b) => a.position - b.position); + const childrenCategories = data + .filter(c => typeof c.parent_id === 'number') + .sort((a, b) => b.position - a.position); + childrenCategories.forEach(c => { + const parentIndex = sortedCategories.findIndex(i => i.id === c.parent_id); + sortedCategories.splice(parentIndex + 1, 0, c); + }); + setProductCategories(sortedCategories); }).catch((error) => onError(error)); }; + /** + * Save list's new order + */ + const handleSave = () => { + // TODO: index to position -> send to API + console.log('save order:', productCategories); + }; + return (

{t('app.admin.store.product_categories.title')}

- +
+ + Plop +
); diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 3aab16990..5aec87ae3 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -22,6 +22,16 @@ display: flex; justify-content: space-between; align-items: center; + .grpBtn { + display: flex; + & > *:not(:first-child) { margin-left: 2.4rem; } + .saveBtn { + background-color: var(--main); + color: var(--gray-soft-lightest); + border: none; + &:hover { opacity: 0.75; } + } + } h2 { margin: 0; @include title-lg; @@ -40,8 +50,8 @@ } &-tree { - & > *:not(:last-of-type) { - margin-bottom: 1.6rem; + & > *:not(:first-child) { + margin-top: 1.6rem; } } &-item { @@ -94,4 +104,4 @@ cursor: grab; } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index baadafef9..ff8113ae1 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "@babel/preset-typescript": "^7.16.7", "@babel/runtime": "^7.17.2", "@claviska/jquery-minicolors": "^2.3.5", + "@dnd-kit/core": "^6.0.5", + "@dnd-kit/sortable": "^7.0.1", "@fortawesome/fontawesome-free": "5.14.0", "@lyracom/embedded-form-glue": "^0.3.3", "@stripe/react-stripe-js": "^1.4.0", diff --git a/yarn.lock b/yarn.lock index 1f2827b84..968221b91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1488,6 +1488,37 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz#d5e0706cf8c6acd8c6032f8d54070af261bbbb2f" integrity sha512-ws57AidsDvREKrZKYffXddNkyaF14iHNHm8VQnZH6t99E8gczjNN0GpvcGny0imC80yQ0tHz1xVUKk/KFQSUyA== +"@dnd-kit/accessibility@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz#3ccbefdfca595b0a23a5dc57d3de96bc6935641c" + integrity sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg== + dependencies: + tslib "^2.0.0" + +"@dnd-kit/core@^6.0.5": + version "6.0.5" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.0.5.tgz#5670ad0dcc83cd51dbf2fa8c6a5c8af4ac0c1989" + integrity sha512-3nL+Zy5cT+1XwsWdlXIvGIFvbuocMyB4NBxTN74DeBaBqeWdH9JsnKwQv7buZQgAHmAH+eIENfS1ginkvW6bCw== + dependencies: + "@dnd-kit/accessibility" "^3.0.0" + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/sortable@^7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-7.0.1.tgz#99c6012bbab4d8bb726c0eef7b921a338c404fdb" + integrity sha512-n77qAzJQtMMywu25sJzhz3gsHnDOUlEjTtnRl8A87rWIhnu32zuP+7zmFjwGgvqfXmRufqiHOSlH7JPC/tnJ8Q== + dependencies: + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/utilities@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.0.tgz#b3e956ea63a1347c9d0e1316b037ddcc6140acda" + integrity sha512-h65/pn2IPCCIWwdlR2BMLqRkDxpTEONA+HQW3n765HBijLYGyrnTCLa2YQt8VVjjSQD6EfFlTE6aS2Q/b6nb2g== + dependencies: + tslib "^2.0.0" + "@emotion/babel-plugin@^11.7.1": version "11.9.2" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.9.2.tgz#723b6d394c89fb2ef782229d92ba95a740576e95" @@ -7452,6 +7483,11 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tslib@^2.0.3, tslib@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" From bd96622d37318a29fe6f61f8b5a600c310917eb8 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 27 Jul 2022 15:35:57 +0200 Subject: [PATCH 017/361] (quality) rename check-list to checklist and added an uncheck all button --- ...form-check-list.tsx => form-checklist.tsx} | 46 +++++++++++-------- .../components/store/product-form.tsx | 4 +- app/frontend/src/stylesheets/application.scss | 2 +- .../modules/form/form-check-list.scss | 17 ------- .../modules/form/form-checklist.scss | 28 +++++++++++ config/locales/app.shared.en.yml | 3 +- 6 files changed, 61 insertions(+), 39 deletions(-) rename app/frontend/src/javascript/components/form/{form-check-list.tsx => form-checklist.tsx} (67%) delete mode 100644 app/frontend/src/stylesheets/modules/form/form-check-list.scss create mode 100644 app/frontend/src/stylesheets/modules/form/form-checklist.scss diff --git a/app/frontend/src/javascript/components/form/form-check-list.tsx b/app/frontend/src/javascript/components/form/form-checklist.tsx similarity index 67% rename from app/frontend/src/javascript/components/form/form-check-list.tsx rename to app/frontend/src/javascript/components/form/form-checklist.tsx index 1299dcd56..63914e2e7 100644 --- a/app/frontend/src/javascript/components/form/form-check-list.tsx +++ b/app/frontend/src/javascript/components/form/form-checklist.tsx @@ -1,4 +1,4 @@ -import React, { BaseSyntheticEvent } from 'react'; +import React from 'react'; import { Controller, Path, FieldPathValue } from 'react-hook-form'; import { FieldValues } from 'react-hook-form/dist/types/fields'; import { FieldPath } from 'react-hook-form/dist/types/path'; @@ -13,16 +13,16 @@ import { FabButton } from '../base/fab-button'; */ export type ChecklistOption = { value: TOptionValue, label: string }; -interface FormCheckListProps extends FormControlledComponent, AbstractFormItemProps { +interface FormChecklistProps extends FormControlledComponent, AbstractFormItemProps { defaultValue?: Array, options: Array>, onChange?: (values: Array) => void, } /** - * This component is a template for an check list component to use within React Hook Form + * This component is a template for a checklist component to use within React Hook Form */ -export const FormCheckList = ({ id, control, label, tooltip, defaultValue, className, rules, disabled, error, warning, formState, onChange, options }: FormCheckListProps) => { +export const FormChecklist = ({ id, control, label, tooltip, defaultValue, className, rules, disabled, error, warning, formState, onChange, options }: FormChecklistProps) => { const { t } = useTranslation('shared'); /** @@ -35,15 +35,15 @@ export const FormCheckList = , values: Array = [], cb: (value: Array) => void) => { - return (event: BaseSyntheticEvent) => { + const toggleCheckbox = (option: ChecklistOption, rhfValues: Array = [], rhfCallback: (value: Array) => void) => { + return (event: React.ChangeEvent) => { let newValues: Array = []; if (event.target.checked) { - newValues = values.concat(option.value); + newValues = rhfValues.concat(option.value); } else { - newValues = values.filter(v => v !== option.value); + newValues = rhfValues.filter(v => v !== option.value); } - cb(newValues); + rhfCallback(newValues); if (typeof onChange === 'function') { onChange(newValues); } @@ -51,26 +51,33 @@ export const FormCheckList = ) => void) => { + const selectAll = (rhfCallback: (value: Array) => void) => { return () => { const newValues: Array = options.map(o => o.value); - cb(newValues); + rhfCallback(newValues); if (typeof onChange === 'function') { onChange(newValues); } }; }; - // Compose classnames from props - const classNames = [ - `${className || ''}` - ].join(' '); + /** + * Mark all options as non-selected + */ + const unselectAll = (rhfCallback: (value: Array) => void) => { + return () => { + rhfCallback([]); + if (typeof onChange === 'function') { + onChange([]); + } + }; + }; return ( } @@ -90,7 +97,10 @@ export const FormCheckList = - {t('app.shared.form_check_list.select_all')} +
+ {t('app.shared.form_checklist.select_all')} + {t('app.shared.form_checklist.unselect_all')} +
); }} /> diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index e4198bbd5..cb388da7f 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -8,7 +8,7 @@ import { Product } from '../../models/product'; import { FormInput } from '../form/form-input'; import { FormSwitch } from '../form/form-switch'; import { FormSelect } from '../form/form-select'; -import { FormCheckList } from '../form/form-check-list'; +import { FormChecklist } from '../form/form-checklist'; import { FormRichText } from '../form/form-rich-text'; import { FabButton } from '../base/fab-button'; import { FabAlert } from '../base/fab-alert'; @@ -177,7 +177,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc - diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index b3543e447..6f01cd5cd 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -38,7 +38,7 @@ @import "modules/form/form-input"; @import "modules/form/form-rich-text"; @import "modules/form/form-switch"; -@import "modules/form/form-check-list"; +@import "modules/form/form-checklist"; @import "modules/group/change-group"; @import "modules/machines/machine-card"; @import "modules/machines/machines-filters"; diff --git a/app/frontend/src/stylesheets/modules/form/form-check-list.scss b/app/frontend/src/stylesheets/modules/form/form-check-list.scss deleted file mode 100644 index f2b255c7d..000000000 --- a/app/frontend/src/stylesheets/modules/form/form-check-list.scss +++ /dev/null @@ -1,17 +0,0 @@ -.form-check-list { - position: relative; - - .form-item-field { - display: block !important; - } - - .checklist { - display: flex; - padding: 16px; - flex-wrap: wrap; - } - - .checklist-item { - flex: 0 0 33.333333%; - } -} diff --git a/app/frontend/src/stylesheets/modules/form/form-checklist.scss b/app/frontend/src/stylesheets/modules/form/form-checklist.scss new file mode 100644 index 000000000..13b058739 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-checklist.scss @@ -0,0 +1,28 @@ +.form-checklist { + position: relative; + + .form-item-field { + display: block !important; + } + + .checklist { + display: flex; + padding: 16px; + flex-wrap: wrap; + + .checklist-item { + flex: 0 0 33.333333%; + + & > input { + margin-right: 1em; + } + } + } + + .actions { + display: flex; + justify-content: space-evenly; + width: 50%; + margin: auto auto 1.2em; + } +} diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 14ff7ba51..123885afa 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -550,5 +550,6 @@ en: validate_button: "Validate the new card" form_multi_select: create_label: "Add {VALUE}" - form_check_list: + form_checklist: select_all: "Select all" + unselect_all: "Unselect all" From d3d4fdf84db8a710657e82cc8b3daeae87bc33cf Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 28 Jul 2022 18:13:18 +0200 Subject: [PATCH 018/361] (wip) Style product's components --- .../components/form/form-checklist.tsx | 4 +- .../store/categories/product-categories.tsx | 2 +- .../components/store/edit-product.tsx | 4 +- .../components/store/new-product.tsx | 4 +- .../components/store/product-form.tsx | 89 ++++++++++++------- .../javascript/components/store/products.tsx | 10 ++- app/frontend/src/stylesheets/application.scss | 2 + .../stylesheets/modules/base/fab-button.scss | 13 +++ .../modules/base/fab-output-copy.scss | 2 +- .../modules/form/form-checklist.scss | 30 +++---- .../stylesheets/modules/store/_utilities.scss | 14 +++ .../modules/store/product-categories.scss | 39 +++----- .../stylesheets/modules/store/products.scss | 73 +++++++++++++++ .../src/stylesheets/variables/decoration.scss | 1 + .../templates/admin/store/product_edit.html | 4 +- .../templates/admin/store/product_new.html | 4 +- 16 files changed, 207 insertions(+), 88 deletions(-) create mode 100644 app/frontend/src/stylesheets/modules/store/_utilities.scss create mode 100644 app/frontend/src/stylesheets/modules/store/products.scss diff --git a/app/frontend/src/javascript/components/form/form-checklist.tsx b/app/frontend/src/javascript/components/form/form-checklist.tsx index 63914e2e7..ac457a248 100644 --- a/app/frontend/src/javascript/components/form/form-checklist.tsx +++ b/app/frontend/src/javascript/components/form/form-checklist.tsx @@ -98,8 +98,8 @@ export const FormChecklist =
- {t('app.shared.form_checklist.select_all')} - {t('app.shared.form_checklist.unselect_all')} + {t('app.shared.form_checklist.select_all')} + {t('app.shared.form_checklist.unselect_all')}
); diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx index e7cae432f..449a965fd 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -85,7 +85,7 @@ const ProductCategories: React.FC = ({ onSuccess, onErro - Plop + Plop
diff --git a/app/frontend/src/javascript/components/store/edit-product.tsx b/app/frontend/src/javascript/components/store/edit-product.tsx index 384f4d6a0..27ef76e1f 100644 --- a/app/frontend/src/javascript/components/store/edit-product.tsx +++ b/app/frontend/src/javascript/components/store/edit-product.tsx @@ -39,7 +39,9 @@ const EditProduct: React.FC = ({ productId, onSuccess, onError if (product) { return ( - +
+ +
); } return null; diff --git a/app/frontend/src/javascript/components/store/new-product.tsx b/app/frontend/src/javascript/components/store/new-product.tsx index e3f2ca4d2..44c336ffa 100644 --- a/app/frontend/src/javascript/components/store/new-product.tsx +++ b/app/frontend/src/javascript/components/store/new-product.tsx @@ -43,7 +43,9 @@ const NewProduct: React.FC = ({ onSuccess, onError }) => { }; return ( - +
+ +
); }; diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index cb388da7f..b4caa92a3 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -119,19 +119,26 @@ export const ProductForm: React.FC = ({ product, title, onSucc return ( <> -

{title}

- {t('app.admin.store.product_form.save')} +
+

{title}

+
+ {t('app.admin.store.product_form.save')} +
+
- - +
+ + +
= ({ product, title, onSucc id="is_active" formState={formState} label={t('app.admin.store.product_form.is_show_in_store')} /> + +
+
-

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

- +
+

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

+ +
{isActivePrice &&
- - +
+ + +
} + +
+

{t('app.admin.store.product_form.assigning_category')}

@@ -173,6 +190,9 @@ export const ProductForm: React.FC = ({ product, title, onSucc id="product_category_id" formState={formState} label={t('app.admin.store.product_form.linking_product_to_category')} /> + +
+

{t('app.admin.store.product_form.assigning_machines')}

@@ -181,6 +201,9 @@ export const ProductForm: React.FC = ({ product, title, onSucc control={control} id="machine_ids" formState={formState} /> + +
+

{t('app.admin.store.product_form.product_description')}

@@ -191,7 +214,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc id="description" />
- {t('app.admin.store.product_form.save')} + {t('app.admin.store.product_form.save')}
diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 7f60082a3..5abf35b0f 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -58,9 +58,13 @@ const Products: React.FC = ({ onSuccess, onError }) => { }; return ( -
-

{t('app.admin.store.products.all_products')}

- {t('app.admin.store.products.create_a_product')} +
+
+

{t('app.admin.store.products.all_products')}

+
+ {t('app.admin.store.products.create_a_product')} +
+
input { - background-color: var(--gray-soft); + background-color: var(--gray-soft-dark); border-top-right-radius: 0; border-bottom-right-radius: 0; } diff --git a/app/frontend/src/stylesheets/modules/form/form-checklist.scss b/app/frontend/src/stylesheets/modules/form/form-checklist.scss index 13b058739..4703c2313 100644 --- a/app/frontend/src/stylesheets/modules/form/form-checklist.scss +++ b/app/frontend/src/stylesheets/modules/form/form-checklist.scss @@ -1,28 +1,28 @@ .form-checklist { - position: relative; - .form-item-field { - display: block !important; + display: flex; + flex-direction: column; + border: none; } .checklist { - display: flex; - padding: 16px; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 1.6rem 3.2rem; - .checklist-item { - flex: 0 0 33.333333%; - - & > input { - margin-right: 1em; - } + .checklist-item input { + margin-right: 1em; } } .actions { + align-self: flex-end; + margin: 2.4rem 0; display: flex; - justify-content: space-evenly; - width: 50%; - margin: auto auto 1.2em; + justify-content: flex-end; + align-items: center; + & > *:not(:first-child) { + margin-left: 1.6rem; + } } } diff --git a/app/frontend/src/stylesheets/modules/store/_utilities.scss b/app/frontend/src/stylesheets/modules/store/_utilities.scss new file mode 100644 index 000000000..12cff16e3 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/_utilities.scss @@ -0,0 +1,14 @@ +@mixin btn { + width: 4rem; + height: 4rem; + display: inline-flex; + justify-content: center; + align-items: center; + padding: 0; + background: none; + border: none; + &:active { + color: currentColor; + box-shadow: none; + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 5aec87ae3..bfc60dfbe 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -1,18 +1,3 @@ -@mixin btn { - width: 4rem; - height: 4rem; - display: inline-flex; - justify-content: center; - align-items: center; - padding: 0; - background: none; - border: none; - &:active { - color: currentColor; - box-shadow: none; - } -} - .product-categories { max-width: 1300px; margin: 0 auto; @@ -25,28 +10,28 @@ .grpBtn { display: flex; & > *:not(:first-child) { margin-left: 2.4rem; } - .saveBtn { - background-color: var(--main); + .create-button { + background-color: var(--gray-hard-darkest); + border-color: var(--gray-hard-darkest); color: var(--gray-soft-lightest); - border: none; - &:hover { opacity: 0.75; } + &:hover { + background-color: var(--gray-hard-light); + border-color: var(--gray-hard-light); + } } } h2 { margin: 0; @include title-lg; - color: var(--gray-hard-darkest); + color: var(--gray-hard-darkest) !important; } } - .create-button { - background-color: var(--gray-hard-darkest); - border-color: var(--gray-hard-darkest); + .main-action-btn { + background-color: var(--main); color: var(--gray-soft-lightest); - &:hover { - background-color: var(--gray-hard-light); - border-color: var(--gray-hard-light); - } + border: none; + &:hover { opacity: 0.75; } } &-tree { diff --git a/app/frontend/src/stylesheets/modules/store/products.scss b/app/frontend/src/stylesheets/modules/store/products.scss new file mode 100644 index 000000000..1a52e6eb8 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/products.scss @@ -0,0 +1,73 @@ +.products, +.new-product, +.edit-product { + margin: 0 auto; + padding-bottom: 6rem; + + .back-btn { + margin: 2.4rem 0; + padding: 0.4rem 0.8rem; + display: inline-flex; + align-items: center; + background-color: var(--gray-soft-darkest); + border-radius: var(--border-radius-sm); + color: var(--gray-soft-lightest); + i { margin-right: 0.8rem; } + + &:hover { + background-color: var(--gray-hard-lightest); + cursor: pointer; + } + } + + header { + padding: 2.4rem 0; + display: flex; + justify-content: space-between; + align-items: center; + .grpBtn { + display: flex; + & > *:not(:first-child) { margin-left: 2.4rem; } + } + h2 { + margin: 0; + @include title-lg; + color: var(--gray-hard-darkest) !important; + } + } + + .main-action-btn { + background-color: var(--main); + color: var(--gray-soft-lightest); + border: none; + &:hover { opacity: 0.75; } + } + + .main-actions { + display: flex; + justify-content: center; + align-items: center; + & > *:not(:first-child) { + margin-left: 1.6rem; + } + } +} + +.products { + max-width: 1600px; +} + +.new-product, +.edit-product { + max-width: 1300px; + + .product-form { + .grp { + display: flex; + gap: 2.4rem 3.2rem; + .span-7 { + min-width: 70%; + } + } + } +} diff --git a/app/frontend/src/stylesheets/variables/decoration.scss b/app/frontend/src/stylesheets/variables/decoration.scss index 29a009f81..156514a71 100644 --- a/app/frontend/src/stylesheets/variables/decoration.scss +++ b/app/frontend/src/stylesheets/variables/decoration.scss @@ -1,4 +1,5 @@ :root { --border-radius: 8px; + --border-radius-sm: 4px; --shadow: 0 0 10px rgba(39, 32, 32, 0.25); } \ No newline at end of file diff --git a/app/frontend/templates/admin/store/product_edit.html b/app/frontend/templates/admin/store/product_edit.html index 0bdf21ca9..f098a8b92 100644 --- a/app/frontend/templates/admin/store/product_edit.html +++ b/app/frontend/templates/admin/store/product_edit.html @@ -14,11 +14,11 @@
-
+
- + {{ 'app.admin.store.back_products_list' }} diff --git a/app/frontend/templates/admin/store/product_new.html b/app/frontend/templates/admin/store/product_new.html index eb61f3019..db9dae230 100644 --- a/app/frontend/templates/admin/store/product_new.html +++ b/app/frontend/templates/admin/store/product_new.html @@ -14,11 +14,11 @@
-
+
- + {{ 'app.admin.store.back_products_list' }} From 1cb94e6c3d779a09df90f24e4a8f919412512a6a Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 28 Jul 2022 22:21:23 +0200 Subject: [PATCH 019/361] Fix save-btn color --- .../stylesheets/modules/settings/boolean-setting.scss | 10 +++++++--- .../modules/settings/user-validation-setting.scss | 10 +++++++--- .../src/stylesheets/modules/socials/fab-socials.scss | 8 ++++++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss b/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss index bf8d86c96..04f032429 100644 --- a/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss +++ b/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss @@ -10,9 +10,13 @@ vertical-align: middle; } .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); - color: var(--secondary-text-color); margin-left: 15px; + background-color: var(--secondary); + border-color: var(--secondary); + color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss b/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss index ec368ce3d..75b1c5f3f 100644 --- a/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss +++ b/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss @@ -1,8 +1,12 @@ .user-validation-setting { .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); - color: var(--secondary-text-color); margin-top: 15px; + background-color: var(--secondary); + border-color: var(--secondary); + color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/socials/fab-socials.scss b/app/frontend/src/stylesheets/modules/socials/fab-socials.scss index 4404ae008..44005e290 100644 --- a/app/frontend/src/stylesheets/modules/socials/fab-socials.scss +++ b/app/frontend/src/stylesheets/modules/socials/fab-socials.scss @@ -1,7 +1,11 @@ .fab-socials { .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); + background-color: var(--secondary); + border-color: var(--secondary); color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } From 8c628533953af9107e805234555a418a50b04a44 Mon Sep 17 00:00:00 2001 From: vincent Date: Fri, 29 Jul 2022 10:56:15 +0200 Subject: [PATCH 020/361] Fix button color + standardise class names --- .../store/categories/manage-product-category.tsx | 4 ++-- .../store/categories/product-categories-item.tsx | 2 +- .../modules/store/product-categories.scss | 8 ++++---- .../supporting-documents-files.scss | 12 ++++++++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx index 068c5294f..bf6be5028 100644 --- a/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx +++ b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx @@ -54,12 +54,12 @@ export const ManageProductCategory: React.FC = ({ pr case 'update': return (} - className="edit-button" + className="edit-btn" onClick={toggleModal} />); case 'delete': return (} - className="delete-button" + className="delete-btn" onClick={toggleModal} />); } }; diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx index 9799eed14..ac0bed4cb 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -36,7 +36,7 @@ export const ProductCategoriesItem: React.FC = ({ pr

{category.name}

[count]
-
+
Date: Fri, 29 Jul 2022 10:58:03 +0200 Subject: [PATCH 021/361] (wip) Style products list and form --- .../components/store/product-form.tsx | 32 ++-- .../components/store/products-list.tsx | 28 ++-- .../javascript/components/store/products.tsx | 45 +++++- .../stylesheets/modules/base/fab-button.scss | 11 ++ .../modules/form/form-checklist.scss | 2 +- .../stylesheets/modules/store/products.scss | 149 +++++++++++++++++- app/frontend/templates/admin/store/index.html | 2 +- .../templates/admin/store/product_edit.html | 2 +- .../templates/admin/store/product_new.html | 2 +- 9 files changed, 236 insertions(+), 37 deletions(-) diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index b4caa92a3..e00f38f79 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -126,7 +126,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc
-
+
= ({ product, title, onSucc + label={t('app.admin.store.product_form.sku')} + className='span-3' />
- - + + label={t('app.admin.store.product_form.slug')} + className='span-7' /> + +

-
+

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

+ onChange={toggleIsActivePrice} + className='span-3' />
{isActivePrice &&
-
+
= ({ products, onEdit, on }; return ( -
+ <> {products.map((product) => ( -
- {product.name} -
- - - - - - +
+
+ +

{product.name}

+
+
+
+
+ + + + + + +
))} -
+ ); }; diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 5abf35b0f..6d27af5b1 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -65,11 +65,46 @@ const Products: React.FC = ({ onSuccess, onError }) => { {t('app.admin.store.products.create_a_product')}
- +
+
+
+

Filtrer

+
+ Clear +
+
+
+
+
+
+

Result count: {products.length}

+
+
+
+

Display options:

+
+
+ +
+
+
+
+
+

feature name

+ +
+
+

long feature name

+ +
+
+ +
+
); }; diff --git a/app/frontend/src/stylesheets/modules/base/fab-button.scss b/app/frontend/src/stylesheets/modules/base/fab-button.scss index cb661590a..5bf2ece3f 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-button.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-button.scss @@ -62,4 +62,15 @@ opacity: 0.75; } } + &.is-black { + border-color: var(--gray-hard-darkest); + background-color: var(--gray-hard-darkest); + color: var(--gray-soft-lightest); + &:hover { + border-color: var(--gray-hard-darkest); + background-color: var(--gray-hard-darkest); + color: var(--gray-soft-lightest); + opacity: 0.75; + } + } } diff --git a/app/frontend/src/stylesheets/modules/form/form-checklist.scss b/app/frontend/src/stylesheets/modules/form/form-checklist.scss index 4703c2313..20721b0c5 100644 --- a/app/frontend/src/stylesheets/modules/form/form-checklist.scss +++ b/app/frontend/src/stylesheets/modules/form/form-checklist.scss @@ -7,7 +7,7 @@ .checklist { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.6rem 3.2rem; .checklist-item input { diff --git a/app/frontend/src/stylesheets/modules/store/products.scss b/app/frontend/src/stylesheets/modules/store/products.scss index 1a52e6eb8..05f4d59e6 100644 --- a/app/frontend/src/stylesheets/modules/store/products.scss +++ b/app/frontend/src/stylesheets/modules/store/products.scss @@ -34,6 +34,19 @@ @include title-lg; color: var(--gray-hard-darkest) !important; } + h3 { + margin: 0; + @include text-lg(600); + color: var(--gray-hard-darkest) !important; + } + } + + .layout { + display: flex; + align-items: flex-end; + gap: 0 3.2rem; + .span-7 { flex: 1 1 70%; } + .span-3 { flex: 1 1 30%; } } .main-action-btn { @@ -55,18 +68,146 @@ .products { max-width: 1600px; + + .layout { + align-items: flex-start; + } + + &-filters { + padding-top: 1.6rem; + border-top: 1px solid var(--gray-soft-dark); + } + + &-list { + .status { + padding: 1.6rem 2.4rem; + display: flex; + justify-content: space-between; + background-color: var(--gray-soft); + border-radius: var(--border-radius); + p { margin: 0; } + .count { + p { + display: flex; + align-items: center; + @include text-sm; + span { + margin-left: 1.6rem; + @include text-lg(600); + } + } + } + } + .features { + margin: 2.4rem 0 1.6rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1.6rem 2.4rem; + &-item { + padding-left: 1.6rem; + display: flex; + align-items: center; + background-color: var(--information-light); + border-radius: 100px; + color: var(--information-dark); + p { margin: 0; } + button { + width: 3.2rem; + height: 3.2rem; + background: none; + border: none; + } + } + } + + &-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0.8rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + &:not(:first-child) { + margin-top: 1.6rem; + } + + .itemInfo { + display: flex; + justify-content: flex-end; + align-items: center; + + &-thumbnail { + width: 4.8rem; + height: 4.8rem; + margin-right: 1.6rem; + object-fit: cover; + border-radius: var(--border-radius); + background-color: var(--gray-soft); + } + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + } + + .actions { + display: flex; + justify-content: flex-end; + align-items: center; + .manage { + overflow: hidden; + display: flex; + border-radius: var(--border-radius-sm); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-btn {background: var(--gray-hard-darkest) } + .delete-btn {background: var(--error) } + } + } + } + } } .new-product, .edit-product { max-width: 1300px; + padding-right: 1.6rem; + padding-left: 1.6rem; .product-form { - .grp { + .flex { display: flex; - gap: 2.4rem 3.2rem; - .span-7 { - min-width: 70%; + flex-wrap: wrap; + align-items: flex-end; + gap: 0 3.2rem; + & > * { + flex: 1 1 320px; + } + } + + .layout { + @media (max-width: 1023px) { + .span-3, + .span-7 { + flex-basis: 50%; + } + } + @media (max-width: 767px) { + flex-wrap: wrap; + } + } + + .price-data { + .layout { + align-items: center; } } } diff --git a/app/frontend/templates/admin/store/index.html b/app/frontend/templates/admin/store/index.html index 0b7557e96..745b51c57 100644 --- a/app/frontend/templates/admin/store/index.html +++ b/app/frontend/templates/admin/store/index.html @@ -17,7 +17,7 @@
-
+
diff --git a/app/frontend/templates/admin/store/product_edit.html b/app/frontend/templates/admin/store/product_edit.html index f098a8b92..6c2f86d18 100644 --- a/app/frontend/templates/admin/store/product_edit.html +++ b/app/frontend/templates/admin/store/product_edit.html @@ -27,7 +27,7 @@
-
+
diff --git a/app/frontend/templates/admin/store/product_new.html b/app/frontend/templates/admin/store/product_new.html index db9dae230..8c4fb9a05 100644 --- a/app/frontend/templates/admin/store/product_new.html +++ b/app/frontend/templates/admin/store/product_new.html @@ -27,7 +27,7 @@
-
+
From c8559c603c9df42ab98d6a241268c240a3b173e9 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 1 Aug 2022 16:17:21 +0200 Subject: [PATCH 022/361] (wip) drag and drop --- .../categories/product-categories-item.tsx | 67 ++-- .../categories/product-categories-tree.tsx | 294 ++++++++++++------ .../stylesheets/modules/store/dropOptions.md | 35 +++ .../modules/store/product-categories.scss | 98 ++++-- package.json | 1 + yarn.lock | 8 + 6 files changed, 356 insertions(+), 147 deletions(-) create mode 100644 app/frontend/src/stylesheets/modules/store/dropOptions.md diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx index ac0bed4cb..5f1143bd4 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -1,14 +1,19 @@ +// TODO: Remove next eslint-disable +/* eslint-disable @typescript-eslint/no-unused-vars */ import React from 'react'; import { ProductCategory } from '../../../models/product-category'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { ManageProductCategory } from './manage-product-category'; -import { DotsSixVertical } from 'phosphor-react'; +import { CaretDown, DotsSixVertical } from 'phosphor-react'; interface ProductCategoriesItemProps { productCategories: Array, category: ProductCategory, - isChild?: boolean, + offset: boolean, + collapsed?: boolean, + handleCollapse?: (id: number) => void, + status: 'child' | 'single' | 'parent', onSuccess: (message: string) => void, onError: (message: string) => void, } @@ -16,41 +21,53 @@ interface ProductCategoriesItemProps { /** * Renders a draggable category item */ -export const ProductCategoriesItem: React.FC = ({ productCategories, category, isChild, onSuccess, onError }) => { +export const ProductCategoriesItem: React.FC = ({ productCategories, category, offset, collapsed, handleCollapse, status, onSuccess, onError }) => { const { attributes, listeners, setNodeRef, transform, - transition + transition, + isDragging } = useSortable({ id: category.id }); const style = { - transform: CSS.Transform.toString(transform), - transition + transition, + transform: CSS.Transform.toString(transform) }; return ( -
-
-

{category.name}

- [count] -
-
-
- - +
+ {(status === 'child' || offset) && +
+ } +
+
+ {status === 'parent' &&
+ +
} +

{category.name}

+ [count]
-
- +
+
+ + +
+
+ +
diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx index 01b8797af..6c1bc0783 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx @@ -1,9 +1,11 @@ +// TODO: Remove next eslint-disable /* eslint-disable @typescript-eslint/no-unused-vars */ import React, { useEffect, useState } from 'react'; import { useImmer } from 'use-immer'; import { ProductCategory } from '../../../models/product-category'; import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, DragMoveEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { restrictToWindowEdges } from '@dnd-kit/modifiers'; import { ProductCategoriesItem } from './product-categories-item'; interface ProductCategoriesTreeProps { @@ -18,13 +20,18 @@ interface ProductCategoriesTreeProps { */ export const ProductCategoriesTree: React.FC = ({ productCategories, onDnd, onSuccess, onError }) => { const [categoriesList, setCategoriesList] = useImmer(productCategories); - const [hiddenChildren, setHiddenChildren] = useState({}); + const [activeData, setActiveData] = useImmer(initActiveData); + // TODO: type extractedChildren: {[parentId]: ProductCategory[]} ??? + const [extractedChildren, setExtractedChildren] = useImmer({}); + const [collapsed, setCollapsed] = useImmer([]); + const [offset, setOffset] = useState(false); - // Initialize state from props, sorting list as a tree + // Initialize state from props useEffect(() => { setCategoriesList(productCategories); }, [productCategories]); + // Dnd Kit config const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { @@ -34,145 +41,238 @@ export const ProductCategoriesTree: React.FC = ({ pr /** * On drag start + * Collect dragged items' data + * Extract children from list */ const handleDragStart = ({ active }: DragMoveEvent) => { - hideChildren(active.id, categoriesList.findIndex(el => el.id === active.id)); - const activeChildren = categoriesList.filter(c => c.parent_id === active.id); - if (activeChildren.length) { - setHiddenChildren({ [active.id]: activeChildren }); - const activeIndex = categoriesList.findIndex(el => el.id === active.id); - const tmpList = [...categoriesList]; - tmpList.splice(activeIndex + 1, activeChildren.length); - setCategoriesList(tmpList); - } + const activeIndex = active.data.current.sortable.index; + const children = getChildren(active.id); + + setActiveData(draft => { + draft.index = activeIndex; + draft.category = getCategory(active.id); + draft.status = getStatus(active.id); + draft.children = children?.length ? children : null; + }); + + setExtractedChildren(draft => { draft[active.id] = children; }); + hideChildren(active.id, activeIndex); }; /** * On drag move */ - const handleDragMove = ({ delta, over }: DragMoveEvent) => { - console.log(findCategory(over.id).name); - if (delta.x > 48) { - console.log('Child'); - } else { - console.log('Parent'); + const handleDragMove = ({ delta, active, over }: DragMoveEvent) => { + if ((getStatus(active.id) === 'single' || getStatus(active.id) === 'child') && getStatus(over.id) === 'single') { + if (delta.x > 32) { + setOffset(true); + } else { + setOffset(false); + } } }; /** - * Update categories list after an item was dropped + * On drag End + * Insert children back in list */ - const handleDragEnd = ({ active, over }: DragMoveEvent) => { let newOrder = [...categoriesList]; + const currentIdsOrder = over?.data.current.sortable.items; + let newIndex = over.data.current.sortable.index; - // si déplacé sur une autre catégorie… - if (active.id !== over.id) { - // liste d'ids des catégories visibles - const previousIdsOrder = over?.data.current.sortable.items; - // index dans previousIdsOrder de la catégorie déplacée - const oldIndex = active.data.current.sortable.index; - // index dans previousIdsOrder de la catégorie de réception - const newIndex = over.data.current.sortable.index; - // liste de catégories mise à jour après le drop - const newIdsOrder = arrayMove(previousIdsOrder, oldIndex, newIndex); - // id du parent de la catégorie de réception - const newParentId = categoriesList[newIndex].parent_id; - - // nouvelle liste de catégories classées par newIdsOrder + // [A] Single |> [B] Single + if (getStatus(active.id) === 'single' && getStatus(over.id) === 'single') { + console.log('[A] Single |> [B] Single'); + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); newOrder = newIdsOrder.map(sortedId => { - // catégorie courante du map retrouvée grâce à l'id - const categoryFromId = findCategory(sortedId); - // si catégorie courante = catégorie déplacée… - if (categoryFromId.id === active.id) { - // maj du parent - categoryFromId.parent_id = newParentId; + let category = getCategory(sortedId); + if (offset && sortedId === active.id && activeData.index < newIndex) { + category = { ...category, parent_id: Number(over.id) }; } - // retour de la catégorie courante - return categoryFromId; + return category; }); } - // insert siblings back - if (hiddenChildren[active.id]?.length) { - newOrder.splice(over.data.current.sortable.index + 1, 0, ...hiddenChildren[active.id]); - setHiddenChildren({ ...hiddenChildren, [active.id]: null }); + + // [A] Child |> [B] Single + if ((getStatus(active.id) === 'child') && getStatus(over.id) === 'single') { + console.log('[A] Child |> [B] Single'); + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (offset && sortedId === active.id && activeData.index < newIndex) { + category = { ...category, parent_id: Number(over.id) }; + } else if (sortedId === active.id && activeData.index < newIndex) { + category = { ...category, parent_id: null }; + } + return category; + }); } + + // [A] Single || Child |>… + if (getStatus(active.id) === 'single' || getStatus(active.id) === 'child') { + // [B] Parent + if (getStatus(over.id) === 'parent') { + if (activeData.index < newIndex) { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: Number(over.id) }; + } + return category; + }); + } else { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: null }; + } + return category; + }); + } + } + // [B] Child + if (getStatus(over.id) === 'child') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: getCategory(over.id).parent_id }; + } + return category; + }); + } + } + + // [A] Parent |>… + if (getStatus(active.id) === 'parent') { + // [B] Single + if (getStatus(over.id) === 'single') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + // [B] Parent + if (getStatus(over.id) === 'parent') { + if (activeData.index < newIndex) { + const lastOverChildIndex = newOrder.findIndex(c => c.id === getChildren(over.id).pop().id); + newIndex = lastOverChildIndex; + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } else { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + } + // [B] Child + if (getStatus(over.id) === 'child') { + if (activeData.index < newIndex) { + const parent = newOrder.find(c => c.id === getCategory(over.id).parent_id); + const lastSiblingIndex = newOrder.findIndex(c => c.id === getChildren(parent.id).pop().id); + newIndex = lastSiblingIndex; + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } else { + const parentIndex = currentIdsOrder.indexOf(getCategory(over.id).parent_id); + newIndex = parentIndex; + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + } + // insert children back + newOrder = showChildren(active.id, newOrder, newIndex); + } + onDnd(newOrder); + setOffset(false); }; /** - * Reset state if the drag was canceled + * On drag cancel + * Reset states */ const handleDragCancel = ({ active }: DragMoveEvent) => { - setHiddenChildren({ ...hiddenChildren, [active.id]: null }); setCategoriesList(productCategories); + setActiveData(initActiveData); + setExtractedChildren({ ...extractedChildren, [active.id]: null }); }; /** - * Hide children by their parent's id + * Get a category by its id */ - const hideChildren = (parentId, parentIndex) => { - const children = findChildren(parentId); - if (children?.length) { - const tmpList = [...categoriesList]; - tmpList.splice(parentIndex + 1, children.length); - setCategoriesList(tmpList); - } - }; - - /** - * Find a category by its id - */ - const findCategory = (id) => { + const getCategory = (id) => { return categoriesList.find(c => c.id === id); }; + /** - * Find the children categories of a parent category by its id + * Get the children categories of a parent category by its id */ - const findChildren = (id) => { + const getChildren = (id) => { const displayedChildren = categoriesList.filter(c => c.parent_id === id); if (displayedChildren.length) { return displayedChildren; } - return hiddenChildren[id]; + return extractedChildren[id]; }; + /** - * Find category's status by its id - * single | parent | child + * Get category's status by its id + * child | single | parent */ - const categoryStatus = (id) => { - const c = findCategory(id); - if (!c.parent_id) { - if (findChildren(id)?.length) { - return 'parent'; - } - return 'single'; - } else { - return 'child'; + const getStatus = (id) => { + const c = getCategory(id); + return !c.parent_id + ? getChildren(id)?.length + ? 'parent' + : 'single' + : 'child'; + }; + + /** + * Extract children from the list by their parent's id + */ + const hideChildren = (parentId, parentIndex) => { + const children = getChildren(parentId); + if (children?.length) { + const shortenList = [...categoriesList]; + shortenList.splice(parentIndex + 1, children.length); + setCategoriesList(shortenList); } }; /** - * Translate visual order into categories data positions + * Insert children back in the list by their parent's id */ - const indexToPosition = (sortedIds: number[]) => { - const sort = sortedIds.map(sortedId => categoriesList.find(el => el.id === sortedId)); - const newPositions = sort.map(c => { - if (typeof c.parent_id === 'number') { - const parentIndex = sort.findIndex(el => el.id === c.parent_id); - const currentIndex = sort.findIndex(el => el.id === c.id); - return { ...c, position: (currentIndex - parentIndex - 1) }; - } - return c; - }); - return newPositions; + const showChildren = (parentId, currentList, insertIndex) => { + if (extractedChildren[parentId]?.length) { + currentList.splice(insertIndex + 1, 0, ...extractedChildren[parentId]); + setExtractedChildren({ ...extractedChildren, [parentId]: null }); + } + return currentList; + }; + + /** + * Toggle parent category by hidding/showing its children + */ + const handleCollapse = (id) => { + const i = collapsed.findIndex(el => el === id); + if (i === -1) { + setCollapsed([...collapsed, id]); + } else { + const copy = [...collapsed]; + copy.splice(i, 1); + setCollapsed(copy); + } }; return ( @@ -185,7 +285,10 @@ export const ProductCategoriesTree: React.FC = ({ pr category={category} onSuccess={onSuccess} onError={onError} - isChild={typeof category.parent_id === 'number'} + offset={category.id === activeData.category?.id && activeData?.offset} + collapsed={collapsed.includes(category.id) || collapsed.includes(category.parent_id)} + handleCollapse={handleCollapse} + status={getStatus(category.id)} /> ))}
@@ -193,3 +296,18 @@ export const ProductCategoriesTree: React.FC = ({ pr ); }; + +interface ActiveData { + index: number, + category: ProductCategory, + status: 'child' | 'single' | 'parent', + children: ProductCategory[], + offset: boolean +} +const initActiveData: ActiveData = { + index: null, + category: null, + status: null, + children: [], + offset: false +}; diff --git a/app/frontend/src/stylesheets/modules/store/dropOptions.md b/app/frontend/src/stylesheets/modules/store/dropOptions.md new file mode 100644 index 000000000..d8ff63b24 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/dropOptions.md @@ -0,0 +1,35 @@ + + +## [A] Single |> [B] Single + [A] = index de [B] + offset && [A] child de [B] + + + + + +## [A] Child |> [B] Single + [A] = index de [B] + offset + ? [A] child de [B] + : [A] Single + + + + + + + +## [A] Single |> [A] + offset && [A] child du précédant parent \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 7e2f2b0dc..1f3585856 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -41,50 +41,80 @@ } &-item { display: flex; - justify-content: space-between; - align-items: center; - padding: 0.6rem 1.6rem; - border: 1px solid var(--gray-soft-dark); - border-radius: var(--border-radius); + pointer-events: all; - .itemInfo { - display: flex; - justify-content: flex-end; - align-items: center; - &-name { - margin: 0; - @include text-base; - font-weight: 600; - color: var(--gray-hard-darkest); - } - &-count { - margin-left: 2.4rem; - @include text-sm; - font-weight: 500; - color: var(--information); - } + &.is-collapsed { + height: 0; + margin: 0; + padding: 0; + border: none; + overflow: hidden; + pointer-events: none; + } + .offset { + width: 4.8rem; } - .actions { + .wrap { + width: 100%; display: flex; - justify-content: flex-end; + justify-content: space-between; align-items: center; - .manage { - overflow: hidden; + padding: 0.6rem 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + .itemInfo { display: flex; - border-radius: var(--border-radius-sm); - button { - @include btn; - border-radius: 0; - color: var(--gray-soft-lightest); - &:hover { opacity: 0.75; } + align-items: center; + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + &-count { + margin-left: 2.4rem; + @include text-sm; + font-weight: 500; + color: var(--information); + } + } + + .actions { + display: flex; + justify-content: flex-end; + align-items: center; + .manage { + overflow: hidden; + display: flex; + border-radius: var(--border-radius-sm); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-btn { background: var(--gray-hard-darkest); } + .delete-btn { background: var(--error); } } - .edit-btn {background: var(--gray-hard-darkest) } - .delete-btn {background: var(--error) } } } - .draghandle { + .collapse-handle { + width: 4rem; + margin: 0 0 0 -1rem; + button { + @include btn; + background: none; + border-radius: 0; + transition: transform 250ms ease-in-out; + &.rotate { + transform: rotateZ(-180deg); + } + } + } + .drag-handle button { @include btn; cursor: grab; } diff --git a/package.json b/package.json index ff8113ae1..852452ee8 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@babel/runtime": "^7.17.2", "@claviska/jquery-minicolors": "^2.3.5", "@dnd-kit/core": "^6.0.5", + "@dnd-kit/modifiers": "^6.0.0", "@dnd-kit/sortable": "^7.0.1", "@fortawesome/fontawesome-free": "5.14.0", "@lyracom/embedded-form-glue": "^0.3.3", diff --git a/yarn.lock b/yarn.lock index 968221b91..b82199d18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1504,6 +1504,14 @@ "@dnd-kit/utilities" "^3.2.0" tslib "^2.0.0" +"@dnd-kit/modifiers@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/modifiers/-/modifiers-6.0.0.tgz#61d8834132f791a68e9e93be5426becbcd45c078" + integrity sha512-V3+JSo6/BTcgPRHiNUTSKgqVv/doKXg+T4Z0QvKiiXp+uIyJTUtPkQOBRQApUWi3ApBhnoWljyt/3xxY4fTd0Q== + dependencies: + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + "@dnd-kit/sortable@^7.0.1": version "7.0.1" resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-7.0.1.tgz#99c6012bbab4d8bb726c0eef7b921a338c404fdb" From c5f2b2587eb3a9638e847746b88efb39128175e8 Mon Sep 17 00:00:00 2001 From: vincent Date: Wed, 3 Aug 2022 09:59:52 +0200 Subject: [PATCH 023/361] Remove test text --- .../components/store/categories/product-categories.tsx | 2 +- app/frontend/templates/admin/calendar/icalendar.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx index 449a965fd..25a7dc617 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -85,7 +85,7 @@ const ProductCategories: React.FC = ({ onSuccess, onErro - Plop + Save
diff --git a/app/frontend/templates/admin/calendar/icalendar.html b/app/frontend/templates/admin/calendar/icalendar.html index ab91191d3..edd60ecfe 100644 --- a/app/frontend/templates/admin/calendar/icalendar.html +++ b/app/frontend/templates/admin/calendar/icalendar.html @@ -38,7 +38,7 @@ - {{calendar.name}} plop + {{calendar.name}} {{calendar.url}} {{ calendar.text_hidden ? '' : 'app.admin.icalendar.example' }} From fc4fd78843502555cf98144db6883806c844da77 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Tue, 2 Aug 2022 19:47:56 +0200 Subject: [PATCH 024/361] product files and images upload --- app/controllers/api/products_controller.rb | 23 +++- app/frontend/src/javascript/api/product.ts | 63 ++++++++- .../components/form/form-file-upload.tsx | 129 ++++++++++++++++++ .../components/form/form-image-upload.tsx | 119 ++++++++++++++++ .../components/store/new-product.tsx | 4 +- .../components/store/product-form.tsx | 85 +++++++++++- app/frontend/src/javascript/models/product.ts | 16 +++ app/frontend/src/stylesheets/application.scss | 3 + .../modules/form/form-file-upload.scss | 106 ++++++++++++++ .../modules/form/form-image-upload.scss | 48 +++++++ .../modules/store/product-form.scss | 4 + app/models/product.rb | 6 + app/models/product_file.rb | 6 + app/models/product_image.rb | 6 + app/uploaders/product_file_uploader.rb | 66 +++++++++ app/uploaders/product_image_uploader.rb | 76 +++++++++++ app/views/api/products/_product.json.jbuilder | 10 ++ config/locales/app.admin.en.yml | 6 + config/locales/app.admin.fr.yml | 6 + config/locales/app.shared.en.yml | 6 + config/locales/app.shared.fr.yml | 6 + 21 files changed, 785 insertions(+), 9 deletions(-) create mode 100644 app/frontend/src/javascript/components/form/form-file-upload.tsx create mode 100644 app/frontend/src/javascript/components/form/form-image-upload.tsx create mode 100644 app/frontend/src/stylesheets/modules/form/form-file-upload.scss create mode 100644 app/frontend/src/stylesheets/modules/form/form-image-upload.scss create mode 100644 app/frontend/src/stylesheets/modules/store/product-form.scss create mode 100644 app/models/product_file.rb create mode 100644 app/models/product_image.rb create mode 100644 app/uploaders/product_file_uploader.rb create mode 100644 app/uploaders/product_image_uploader.rb diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index e411ce090..b21e65be1 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -15,8 +15,13 @@ class API::ProductsController < API::ApiController def create authorize Product @product = Product.new(product_params) - @product.amount = nil if @product.amount.zero? - @product.amount *= 100 if @product.amount.present? + if @product.amount.present? + if @product.amount.zero? + @product.amount = nil + else + @product.amount *= 100 + end + end if @product.save render status: :created else @@ -28,8 +33,13 @@ class API::ProductsController < API::ApiController authorize @product product_parameters = product_params - product_parameters[:amount] = nil if product_parameters[:amount].zero? - product_parameters[:amount] = product_parameters[:amount] * 100 if product_parameters[:amount].present? + if product_parameters[:amount].present? + if product_parameters[:amount].zero? + product_parameters[:amount] = nil + else + product_parameters[:amount] *= 100 + end + end if @product.update(product_parameters) render status: :ok else @@ -52,6 +62,9 @@ class API::ProductsController < API::ApiController def product_params params.require(:product).permit(:name, :slug, :sku, :description, :is_active, :product_category_id, :amount, :quantity_min, - :low_stock_alert, :low_stock_threshold, machine_ids: []) + :low_stock_alert, :low_stock_threshold, + machine_ids: [], + product_files_attributes: %i[id attachment _destroy], + product_images_attributes: %i[id attachment _destroy]) end end diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts index edb434c95..c89a32642 100644 --- a/app/frontend/src/javascript/api/product.ts +++ b/app/frontend/src/javascript/api/product.ts @@ -1,5 +1,6 @@ import apiClient from './clients/api-client'; import { AxiosResponse } from 'axios'; +import { serialize } from 'object-to-formdata'; import { Product } from '../models/product'; export default class ProductAPI { @@ -14,12 +15,70 @@ export default class ProductAPI { } static async create (product: Product): Promise { - const res: AxiosResponse = await apiClient.post('/api/products', { product }); + const data = serialize({ + product: { + ...product, + product_files_attributes: null, + product_images_attributes: null + } + }); + data.delete('product[product_files_attributes]'); + data.delete('product[product_images_attributes]'); + product.product_files_attributes?.forEach((file, i) => { + if (file?.attachment_files && file?.attachment_files[0]) { + data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]); + } + }); + product.product_images_attributes?.forEach((image, i) => { + if (image?.attachment_files && image?.attachment_files[0]) { + data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]); + } + }); + const res: AxiosResponse = await apiClient.post('/api/products', data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); return res?.data; } static async update (product: Product): Promise { - const res: AxiosResponse = await apiClient.patch(`/api/products/${product.id}`, { product }); + const data = serialize({ + product: { + ...product, + product_files_attributes: null, + product_images_attributes: null + } + }); + data.delete('product[product_files_attributes]'); + data.delete('product[product_images_attributes]'); + product.product_files_attributes?.forEach((file, i) => { + if (file?.attachment_files && file?.attachment_files[0]) { + data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]); + } + if (file?.id) { + data.set(`product[product_files_attributes][${i}][id]`, file.id.toString()); + } + if (file?._destroy) { + data.set(`product[product_files_attributes][${i}][_destroy]`, file._destroy.toString()); + } + }); + product.product_images_attributes?.forEach((image, i) => { + if (image?.attachment_files && image?.attachment_files[0]) { + data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]); + } + if (image?.id) { + data.set(`product[product_images_attributes][${i}][id]`, image.id.toString()); + } + if (image?._destroy) { + data.set(`product[product_images_attributes][${i}][_destroy]`, image._destroy.toString()); + } + }); + const res: AxiosResponse = await apiClient.patch(`/api/products/${product.id}`, data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); return res?.data; } diff --git a/app/frontend/src/javascript/components/form/form-file-upload.tsx b/app/frontend/src/javascript/components/form/form-file-upload.tsx new file mode 100644 index 000000000..b545ebbbb --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-file-upload.tsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Path } from 'react-hook-form'; +import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { FieldPathValue } from 'react-hook-form/dist/types/path'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FormInput } from '../form/form-input'; +import { FormComponent } from '../../models/form-component'; +import { AbstractFormItemProps } from './abstract-form-item'; + +export interface FileType { + id?: number, + attachment_name?: string, + attachment_url?: string +} + +interface FormFileUploadProps extends FormComponent, AbstractFormItemProps { + setValue: UseFormSetValue, + defaultFile?: FileType, + accept?: string, + onFileChange?: (value: FileType) => void, + onFileRemove?: () => void, +} + +/** + * This component allows to upload file, in forms managed by react-hook-form. + */ +export const FormFileUpload = ({ id, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps) => { + const { t } = useTranslation('shared'); + + const [file, setFile] = useState(defaultFile); + + /** + * Check if file is selected + */ + const hasFile = (): boolean => { + return !!file?.attachment_name; + }; + + /** + * Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled). + */ + function onFileSelected (event: React.ChangeEvent) { + const f = event.target?.files[0]; + if (f) { + setFile({ + attachment_name: f.name + }); + setValue( + `${id}[_destroy]` as Path, + false as UnpackNestedValue>> + ); + if (typeof onFileChange === 'function') { + onFileChange({ attachment_name: f.name }); + } + } + } + + /** + * Callback triggered when the user clicks on the delete button. + */ + function onRemoveFile () { + if (file?.id) { + setValue( + `${id}[_destroy]` as Path, + true as UnpackNestedValue>> + ); + } + setValue( + `${id}[attachment_files]` as Path, + null as UnpackNestedValue>> + ); + setFile(null); + if (typeof onFileRemove === 'function') { + onFileRemove(); + } + } + + // Compose classnames from props + const classNames = [ + `${className || ''}` + ].join(' '); + + return ( +
+
+ {hasFile() && ( +
+ + + {file.attachment_name} + +
+ )} + {file?.id && file?.attachment_url && ( + + + + )} +
+ + {!hasFile() && ( + {t('app.shared.form_file_upload.browse')} + )} + {hasFile() && ( + {t('app.shared.form_file_upload.edit')} + )} + + + {hasFile() && ( + + + + )} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/form/form-image-upload.tsx b/app/frontend/src/javascript/components/form/form-image-upload.tsx new file mode 100644 index 000000000..76bde2632 --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-image-upload.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Path } from 'react-hook-form'; +import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { FieldPathValue } from 'react-hook-form/dist/types/path'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FormInput } from '../form/form-input'; +import { FormComponent } from '../../models/form-component'; +import { AbstractFormItemProps } from './abstract-form-item'; +import { FabButton } from '../base/fab-button'; +import noAvatar from '../../../../images/no_avatar.png'; + +export interface ImageType { + id?: number, + attachment_name?: string, + attachment_url?: string +} + +interface FormImageUploadProps extends FormComponent, AbstractFormItemProps { + setValue: UseFormSetValue, + defaultImage?: ImageType, + accept?: string, + size?: 'small' | 'large' + onFileChange?: (value: ImageType) => void, + onFileRemove?: () => void, +} + +/** + * This component allows to upload image, in forms managed by react-hook-form. + */ +export const FormImageUpload = ({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size }: FormImageUploadProps) => { + const { t } = useTranslation('shared'); + + const [file, setFile] = useState(defaultImage); + const [image, setImage] = useState(defaultImage.attachment_url); + + /** + * Check if image is selected + */ + const hasImage = (): boolean => { + return !!file?.attachment_name; + }; + + /** + * Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled). + */ + function onFileSelected (event: React.ChangeEvent) { + const f = event.target?.files[0]; + if (f) { + const reader = new FileReader(); + reader.onload = (): void => { + setImage(reader.result); + }; + reader.readAsDataURL(f); + setFile({ + attachment_name: f.name + }); + setValue( + `${id}[_destroy]` as Path, + false as UnpackNestedValue>> + ); + if (typeof onFileChange === 'function') { + onFileChange({ attachment_name: f.name }); + } + } + } + + /** + * Callback triggered when the user clicks on the delete button. + */ + function onRemoveFile () { + if (file?.id) { + setValue( + `${id}[_destroy]` as Path, + true as UnpackNestedValue>> + ); + } + setValue( + `${id}[attachment_files]` as Path, + null as UnpackNestedValue>> + ); + setFile(null); + setImage(null); + if (typeof onFileRemove === 'function') { + onFileRemove(); + } + } + + // Compose classnames from props + const classNames = [ + `${className || ''}` + ].join(' '); + + return ( +
+
+ +
+
+ + {!hasImage() && {t('app.shared.form_image_upload.browse')}} + {hasImage() && {t('app.shared.form_image_upload.edit')}} + + + {hasImage() && } className="delete-image" />} +
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/new-product.tsx b/app/frontend/src/javascript/components/store/new-product.tsx index 44c336ffa..5747e50ed 100644 --- a/app/frontend/src/javascript/components/store/new-product.tsx +++ b/app/frontend/src/javascript/components/store/new-product.tsx @@ -31,7 +31,9 @@ const NewProduct: React.FC = ({ onSuccess, onError }) => { external: 0 }, low_stock_alert: false, - machine_ids: [] + machine_ids: [], + product_files_attributes: [], + product_images_attributes: [] }; /** diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index e00f38f79..e2139568f 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import slugify from 'slugify'; import _ from 'lodash'; @@ -10,6 +10,8 @@ import { FormSwitch } from '../form/form-switch'; import { FormSelect } from '../form/form-select'; import { FormChecklist } from '../form/form-checklist'; import { FormRichText } from '../form/form-rich-text'; +import { FormFileUpload } from '../form/form-file-upload'; +import { FormImageUpload } from '../form/form-image-upload'; import { FabButton } from '../base/fab-button'; import { FabAlert } from '../base/fab-alert'; import ProductCategoryAPI from '../../api/product-category'; @@ -41,6 +43,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc const { t } = useTranslation('admin'); const { handleSubmit, register, control, formState, setValue, reset } = useForm({ defaultValues: { ...product } }); + const output = useWatch({ control }); const [isActivePrice, setIsActivePrice] = useState(product.id && _.isFinite(product.amount) && product.amount > 0); const [productCategories, setProductCategories] = useState([]); const [machines, setMachines] = useState([]); @@ -117,6 +120,46 @@ export const ProductForm: React.FC = ({ product, title, onSucc } }; + /** + * Add new product file + */ + const addProductFile = () => { + setValue('product_files_attributes', output.product_files_attributes.concat({})); + }; + + /** + * Remove a product file + */ + const handleRemoveProductFile = (i: number) => { + return () => { + const productFile = output.product_files_attributes[i]; + if (!productFile.id) { + output.product_files_attributes.splice(i, 1); + setValue('product_files_attributes', output.product_files_attributes); + } + }; + }; + + /** + * Add new product image + */ + const addProductImage = () => { + setValue('product_images_attributes', output.product_images_attributes.concat({})); + }; + + /** + * Remove a product image + */ + const handleRemoveProductImage = (i: number) => { + return () => { + const productImage = output.product_images_attributes[i]; + if (!productImage.id) { + output.product_images_attributes.splice(i, 1); + setValue('product_images_attributes', output.product_images_attributes); + } + }; + }; + return ( <>
@@ -187,6 +230,28 @@ export const ProductForm: React.FC = ({ product, title, onSucc
+
+

{t('app.admin.store.product_form.product_images')}

+ + + +
+ {output.product_images_attributes.map((image, i) => ( + + ))} +
+ {t('app.admin.store.product_form.add_product_image')} +

{t('app.admin.store.product_form.assigning_category')}

@@ -218,6 +283,24 @@ export const ProductForm: React.FC = ({ product, title, onSucc paragraphTools={true} limit={1000} id="description" /> +
+

{t('app.admin.store.product_form.product_files')}

+ + + + {output.product_files_attributes.map((file, i) => ( + + ))} + {t('app.admin.store.product_form.add_product_file')} +
{t('app.admin.store.product_form.save')} diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index 9038dbd5c..471e00248 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -22,4 +22,20 @@ export interface Product { low_stock_alert: boolean, low_stock_threshold?: number, machine_ids: number[], + product_files_attributes: Array<{ + id?: number, + attachment?: File, + attachment_files?: FileList, + attachment_name?: string, + attachment_url?: string + _destroy?: boolean + }>, + product_images_attributes: Array<{ + id?: number, + attachment?: File, + attachment_files?: FileList, + attachment_name?: string, + attachment_url?: string + _destroy?: boolean + }> } diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index a461b6ae3..704204551 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -39,6 +39,8 @@ @import "modules/form/form-rich-text"; @import "modules/form/form-switch"; @import "modules/form/form-checklist"; +@import "modules/form/form-file-upload"; +@import "modules/form/form-image-upload"; @import "modules/group/change-group"; @import "modules/machines/machine-card"; @import "modules/machines/machines-filters"; @@ -102,6 +104,7 @@ @import "modules/user/gender-input"; @import "modules/user/user-profile-form"; @import "modules/user/user-validation"; +@import "modules/store/product-form"; @import "modules/abuses"; @import "modules/cookies"; diff --git a/app/frontend/src/stylesheets/modules/form/form-file-upload.scss b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss new file mode 100644 index 000000000..284bc29c4 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss @@ -0,0 +1,106 @@ +.fileinput { + display: table; + border-collapse: separate; + position: relative; + margin-bottom: 9px; + + .filename-container { + align-items: center; + display: inline-flex; + float: left; + margin-bottom: 0; + position: relative; + width: 100%; + z-index: 2; + background-color: #fff; + background-image: none; + border: 1px solid #c4c4c4; + border-radius: 4px; + box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%); + height: 38px; + padding: 6px 12px; + transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; + color: #555; + font-size: 16px; + line-height: 1.5; + + .fileinput-filename { + vertical-align: bottom; + display: inline-block; + overflow: hidden; + margin-left: 10px; + } + + .file-download { + position: absolute; + right: 10px; + + i { + color: black; + } + } + } + .fileinput-button { + z-index: 1; + border: 1px solid #c4c4c4; + border-left: 0; + border-radius: 0 4px 4px 0; + position: relative; + vertical-align: middle; + background-color: #eee; + color: #555; + font-size: 16px; + font-weight: 400; + line-height: 1; + padding: 6px 12px; + text-align: center; + white-space: nowrap; + width: 1%; + display: table-cell; + background-image: none; + touch-action: manipulation; + overflow: hidden; + cursor: pointer; + border-collapse: separate; + border-spacing: 0; + + .form-input { + position: absolute; + z-index: 2; + opacity: 0; + top: 0; + left: 0; + } + + input[type=file] { + display: block; + cursor: pointer; + direction: ltr; + filter: alpha(opacity=0); + font-size: 23px; + height: 100%; + margin: 0; + opacity: 0; + position: absolute; + right: 0; + top: 0; + width: 100%; + } + } + + .fileinput-delete { + padding: 6px 12px; + font-size: 16px; + font-weight: 400; + line-height: 1; + color: #555555; + text-align: center; + background-color: #eeeeee; + border: 1px solid #c4c4c4; + border-radius: 4px; + width: 1%; + white-space: nowrap; + vertical-align: middle; + display: table-cell; + } +} diff --git a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss new file mode 100644 index 000000000..fa35d9a26 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss @@ -0,0 +1,48 @@ +.form-image-upload { + + .image { + background-color: #fff; + border: 1px solid var(--gray-soft); + padding: 4px; + display: inline-block; + + &--small img { + width: 50px; + height: 50px; + } + + &--large img { + width: 180px; + height: 180px; + } + } + + .buttons { + display: flex; + justify-content: center; + margin-top: 20px; + + .select-button { + position: relative; + .image-file-input { + position: absolute; + z-index: 2; + opacity: 0; + top: 0; + left: 0; + } + } + .delete-image { + background-color: var(--error); + color: white; + } + } + + &--large { + margin: 80px 40px; + } + + &--small { + text-align: center; + } +} diff --git a/app/frontend/src/stylesheets/modules/store/product-form.scss b/app/frontend/src/stylesheets/modules/store/product-form.scss new file mode 100644 index 000000000..7c45aa8a7 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-form.scss @@ -0,0 +1,4 @@ +.product-images { + display: flex; + flex-wrap: wrap; +} diff --git a/app/models/product.rb b/app/models/product.rb index d4c26abc2..7bc4087a7 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -6,6 +6,12 @@ class Product < ApplicationRecord has_and_belongs_to_many :machines + has_many :product_files, as: :viewable, dependent: :destroy + accepts_nested_attributes_for :product_files, allow_destroy: true, reject_if: :all_blank + + has_many :product_images, as: :viewable, dependent: :destroy + accepts_nested_attributes_for :product_images, allow_destroy: true, reject_if: :all_blank + validates_numericality_of :amount, greater_than: 0, allow_nil: true scope :active, -> { where(is_active: true) } diff --git a/app/models/product_file.rb b/app/models/product_file.rb new file mode 100644 index 000000000..fd23a9b3f --- /dev/null +++ b/app/models/product_file.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# ProductFile is a file stored on the file system, associated with a Product. +class ProductFile < Asset + mount_uploader :attachment, ProductFileUploader +end diff --git a/app/models/product_image.rb b/app/models/product_image.rb new file mode 100644 index 000000000..9a5da4a85 --- /dev/null +++ b/app/models/product_image.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# ProductImage is an image stored on the file system, associated with a Product. +class ProductImage < Asset + mount_uploader :attachment, ProductImageUploader +end diff --git a/app/uploaders/product_file_uploader.rb b/app/uploaders/product_file_uploader.rb new file mode 100644 index 000000000..1cee5d75c --- /dev/null +++ b/app/uploaders/product_file_uploader.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# CarrierWave uploader for file of product +# This file defines the parameters for these uploads. +class ProductFileUploader < CarrierWave::Uploader::Base + # Include RMagick or MiniMagick support: + # include CarrierWave::RMagick + # include CarrierWave::MiniMagick + include UploadHelper + + # Choose what kind of storage to use for this uploader: + storage :file + # storage :fog + + after :remove, :delete_empty_dirs + + # Override the directory where uploaded files will be stored. + # This is a sensible default for uploaders that are meant to be mounted: + def store_dir + "#{base_store_dir}/#{model.id}" + end + + def base_store_dir + "uploads/#{model.class.to_s.underscore}" + end + + # Provide a default URL as a default if there hasn't been a file uploaded: + # def default_url + # # For Rails 3.1+ asset pipeline compatibility: + # # ActionController::Base.helpers.asset_pack_path("fallback/" + [version_name, "default.png"].compact.join('_')) + # + # "/images/fallback/" + [version_name, "default.png"].compact.join('_') + # end + + # Process files as they are uploaded: + # process :scale => [200, 300] + # + # def scale(width, height) + # # do something + # end + + # Create different versions of your uploaded files: + # version :thumb do + # process :resize_to_fit => [50, 50] + # end + + # Add a white list of extensions which are allowed to be uploaded. + # For images you might use something like this: + def extension_whitelist + %w[pdf] + end + + def content_type_whitelist + ['application/pdf'] + end + + # Override the filename of the uploaded files: + # Avoid using model.id or version_name here, see uploader/store.rb for details. + def filename + if original_filename + original_filename.split('.').map do |s| + ActiveSupport::Inflector.transliterate(s).to_s + end.join('.') + end + end +end diff --git a/app/uploaders/product_image_uploader.rb b/app/uploaders/product_image_uploader.rb new file mode 100644 index 000000000..a7830ab81 --- /dev/null +++ b/app/uploaders/product_image_uploader.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# CarrierWave uploader for image of product +# This file defines the parameters for these uploads. +class ProductImageUploader < CarrierWave::Uploader::Base + include CarrierWave::MiniMagick + include UploadHelper + + # Choose what kind of storage to use for this uploader: + storage :file + after :remove, :delete_empty_dirs + + # Override the directory where uploaded files will be stored. + # This is a sensible default for uploaders that are meant to be mounted: + def store_dir + "#{base_store_dir}/#{model.id}" + end + + def base_store_dir + "uploads/#{model.class.to_s.underscore}" + end + + # Provide a default URL as a default if there hasn't been a file uploaded: + # def default_url + # # For Rails 3.1+ asset pipeline compatibility: + # # ActionController::Base.helpers.asset_pack_path("fallback/" + [version_name, "default.png"].compact.join('_')) + # + # "/images/fallback/" + [version_name, "default.png"].compact.join('_') + # end + + # Process files as they are uploaded: + # process :scale => [200, 300] + # + # def scale(width, height) + # # do something + # end + + # Create different versions of your uploaded files: + # version :thumb do + # process :resize_to_fit => [50, 50] + # end + + # Create different versions of your uploaded files: + version :large do + process resize_to_fit: [1000, 700] + end + + version :medium do + process resize_to_fit: [700, 400] + end + + # Add a white list of extensions which are allowed to be uploaded. + # For images you might use something like this: + def extension_whitelist + %w[jpg jpeg gif png] + end + + def content_type_whitelist + [%r{image/}] + end + + # Override the filename of the uploaded files: + # Avoid using model.id or version_name here, see uploader/store.rb for details. + def filename + if original_filename + original_filename.split('.').map do |s| + ActiveSupport::Inflector.transliterate(s).to_s + end.join('.') + end + end + + # return an array like [width, height] + def dimensions + ::MiniMagick::Image.open(file.file)[:dimensions] + end +end diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder index 624f8e45d..9b9a99336 100644 --- a/app/views/api/products/_product.json.jbuilder +++ b/app/views/api/products/_product.json.jbuilder @@ -2,3 +2,13 @@ json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold, :machine_ids json.amount product.amount / 100.0 if product.amount.present? +json.product_files_attributes product.product_files do |f| + json.id f.id + json.attachment_name f.attachment_identifier + json.attachment_url f.attachment_url +end +json.product_images_attributes product.product_images do |f| + json.id f.id + json.attachment_name f.attachment_identifier + json.attachment_url f.attachment_url +end diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 103e8af33..ee7e70f66 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1954,4 +1954,10 @@ en: assigning_machines_info: "Information
You can link one or more machines from your fablab to your product, this product will then be subject to the filters on the catalogue view.
The machines selected below will be linked to the product." product_description: "Product description" product_description_info: "Information
This product description will be present in the product sheet. You have a few editorial styles at your disposal to create the product sheet." + product_files: "Document" + product_files_info: "Information
Add documents related to this product, the uploaded documents will be presented in the product sheet, in a separate block. You can only upload pdf documents." + add_product_file: "Add a document" + product_images: "Images of product" + product_images_info: "Advice
We advise you to use a square format, jpg or png, for jpgs, please use white for the background colour. The main visual will be the visual presented first in the product sheet." + add_product_image: "Add an image" save: "Save" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index fabc04045..2e9847512 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1953,4 +1953,10 @@ fr: assigning_machines_info: "Information
Vous pouvez lier une ou plusieurs machines de votre fablab à votre produit, Ce produit sera alors assujetti aux filtres sur la vue catalogue.
Les machines sélectionnées ci-dessous seront liées au produit." product_description: "Description du produit" product_description_info: "Information
Cette description du produit sera présente dans la fiche du produit. Vous avez à disposition quelques styles rédactionnels pour créer la fiche du produit." + product_files: "Documentation" + product_files_info: "Information
Ajouter des documents liés à ce produit, les document uploadés seront présentés dans la fiche produit, dans un bloc distinct. Vous pouvez uploadé des pdf uniquement." + add_product_file: "Ajouter un document" + product_images: "Visuel(s) du produit" + product_images_info: "Conseils
Nous vous conseillons d'utiliser un format carré, jpg ou png, pour les jpgs, merci de privilégier le blanc pour la couleur de fond. Le visuel principal sera le visuel présenté en premier dans la fiche produit." + add_product_image: "Ajouter un visuel" save: "Enregistrer" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 123885afa..3fc1e941e 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -553,3 +553,9 @@ en: form_checklist: select_all: "Select all" unselect_all: "Unselect all" + form_file_upload: + browse: "Browse" + edit: "Edit" + form_image_upload: + browse: "Browse" + edit: "Edit" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index 0827df858..b72887968 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -552,3 +552,9 @@ fr: create_label: "Ajouter {VALUE}" form_check_list: select_all: "Tout sélectionner" + form_file_upload: + browse: "Parcourir" + edit: "Modifier" + form_image_upload: + browse: "Parcourir" + edit: "Modifier" From 8f38ff79d7d0776509ef61a1fbdd7fbbb5753f2f Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 3 Aug 2022 10:30:30 +0200 Subject: [PATCH 025/361] update locale fr --- config/locales/app.admin.fr.yml | 12 ++++++------ config/locales/app.shared.fr.yml | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 2e9847512..96c21c09c 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1904,9 +1904,9 @@ fr: title: "Les catégories" info: "Information:
Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveaux maximum, vous pouvez les agencer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. Veillez au bon agencement de vos catégories et sauvegarder votre choix." manage_product_category: - create: "Create a product category" - update: "Update the product category" - delete: "Delete the product category" + create: "Créer une catégorie" + update: "Modifier la catégorie" + delete: "Supprimer la catégorie" product_category_modal: new_product_category: "Créer une catégorie" edit_product_category: "Modifier la catégorie" @@ -1921,12 +1921,12 @@ fr: error: "Impossible de modifier la catégorie : " success: "La nouvelle catégorie a bien été mise à jour." delete: - confirm: "Do you really want to delete this product category?" + confirm: "Voulez-vous vraiment supprimer cette catégorie de produits ?" error: "Impossible de supprimer the catégorie : " success: "La catégorie a bien été supprimée" save: "Enregistrer" - required: "This field is required" - slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + required: "Le champ est requise" + slug_pattern: "Uniquement des groupes de caractères alphanumériques minuscules séparés par un trait d'union." products: all_products: "Tous les produits" create_a_product: "Créer un produit" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index b72887968..139728a02 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -550,8 +550,9 @@ fr: validate_button: "Valider la nouvelle carte" form_multi_select: create_label: "Ajouter {VALUE}" - form_check_list: + form_checklist: select_all: "Tout sélectionner" + unselect_all: "Tout désélectionner" form_file_upload: browse: "Parcourir" edit: "Modifier" From be90d0720b7913a86bfbf56c25a2112e10af084e Mon Sep 17 00:00:00 2001 From: vincent Date: Wed, 3 Aug 2022 18:30:29 +0200 Subject: [PATCH 026/361] Product form style --- app/frontend/images/no_avatar.png | Bin 619 -> 792 bytes app/frontend/images/no_image.png | Bin 0 -> 686 bytes .../components/form/form-file-upload.tsx | 64 +++++----- .../components/form/form-image-upload.tsx | 41 ++++--- .../javascript/components/form/form-input.tsx | 1 + .../categories/manage-product-category.tsx | 5 +- .../categories/product-categories-item.tsx | 2 +- .../components/store/product-form.tsx | 110 +++++++++++------- .../components/store/products-list.tsx | 5 +- .../stylesheets/modules/base/fab-button.scss | 59 +++++----- .../modules/form/abstract-form-item.scss | 11 ++ .../modules/form/form-file-upload.scss | 28 ++++- .../modules/form/form-image-upload.scss | 67 +++++------ .../modules/store/product-form.scss | 66 ++++++++++- .../stylesheets/modules/store/products.scss | 30 ----- config/locales/app.admin.en.yml | 3 +- 16 files changed, 290 insertions(+), 202 deletions(-) mode change 100755 => 100644 app/frontend/images/no_avatar.png create mode 100644 app/frontend/images/no_image.png diff --git a/app/frontend/images/no_avatar.png b/app/frontend/images/no_avatar.png old mode 100755 new mode 100644 index dbbb16222434aa28ffb661acee2e6284cd3ca408..bdde278287e23310fa5fb6c801a6a94096845221 GIT binary patch literal 792 zcmeAS@N?(olHy`uVBq!ia0vp^Q6S901|%(3I5Gh#&H|6fVg?3oVGw3ym^DWND9BhG zvnk7*|a*>r>aTb5{t^sWoI`pFaOl~T=y#M}NB}n1-^42M?92X~_JQ8F$ z|GX~KyG7Sue?7aqJNm$SRi}`4fw{9dinp%LnDa^EVq5itl9!#UUmSjzpkvTl@kndd z&kz2(zh)`?%K!e0XQ`*>jxIN$4%SPn=GpXSJXvd7C>p!^YR}Q&?qla?PP9H|yJxk* zyKY70e3^xx9_dM4oMyC2duo?Uyr<;V!w)Yg2{@T3S+(X&*)qd=_Ir-v1kGLN#MkCY zth&okbYOAj*|_!TYr{?}3BKdtyO|-mV|w0pX+I;5t9cDd4C~yBugsYKvDGze1*hV# zdu=o4cc|P6HCZLM_@YMTRMtfs4U+udw$9E72|xC@@Y10-?gDq44>rt-|Jl)UK&olu)TbO&0N2;%rzUYS8q-Y-d;XKPswCU0?(O41`9gv=KB4d^CosT zvjp?8LphEMI*(=cZ~gF>t%ucGPE6$D^=1!~4Qub@m>p|Sn)TJ-`vES7u8cJw=C!x3 vKTy`c^*6^)`Dy<Or?XtqTj)>_f6xbP0l+XkK2rgEW literal 619 zcmeAS@N?(olHy`uVBq!ia0vp^Q6S901SGegx)utgBuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztF!6f2IEGZ*dUMOV`;ddcfsebLSw*)PBphMynmhIWf9@O0 zCYU^8b}>odV&%;!W6efsIA1cQRu>nC*g&idtixWZ=M z`RD&-W%=6QmffDVG1+W(?8XTnZ2aStqAk=Wd*+-gvD(_?+}3mXrO0eC7Do}+o6mpD za-TONllAz=iX}XYwYJVwo`3%EN7<`cUtiADe5Ntg%m2KOq5Imf-KxG~`bU#4c5PgI z{q@mhrMqLJ)27w!ueV(opb@d9W%721Yiq)VQhM`Kn2f^8PaMwQ8dWbhsZ2TcaDn8t zmnHAA&RZP+S+j4o?PHnSZ@;bkrc?ap`|tl|N0!9)MI8}rdzfLeYm$O{Zw{Z@-qJlW zdQZC?1@26WS$|!+x9#<=PTyUeg(ntI<6axKI(i#x{q&TxX_pJG{Q~TdCdQ$G-m#FH)3i>9(ro`sldF#mwyOAkoW;s#@t}bjLNFn*5uz3kHdx(b bm0&pXd=Ibh4qaDZ>S6G7^>bP0l+XkK&kqpn diff --git a/app/frontend/images/no_image.png b/app/frontend/images/no_image.png new file mode 100644 index 0000000000000000000000000000000000000000..9d77d405fe6066ce3a2641f5b58a54766dfd4115 GIT binary patch literal 686 zcmeAS@N?(olHy`uVBq!ia0vp^Q6S901|%(3I5Gh#&H|6fVg?3oVGw3ym^DWND9BhG zLJ#Ete^B<;~oV{#xd0Kz}=8k-uAH7S91&`Bv@sw_}ZJ7Hr;#hLM~QLFKzS8qXzTOuid4`;@gI> z@t}W1+|%QC>MUNUOFaH~BWmrmr#dHXtM=djZ(F@5ZvD2XwL0aVZR_M7&pV%fI(E`R z>$!dsMqGR2&KFI8B5^mzEZ1soo8pqYdF>x|dY2h}x$!>c%g(!b+hX(%ul1j^_~Hqv z-nOMmH!r;Zu%r8^lFyuT`TEnlebpwXB&FBO#P-H~IR0BIN!B;oA^HIox6a%wmPx5 zn{WJ=-nj?JX{}R@viw%Ad|WKIr_MC$r|$gh6mi`z))$NTVlK4SV+6RP^K|=y-rhKI zlM=7_X}`Ds^L(LMvHw2*{hJ@S{}|RsJ(Rw4z4gn&{ibOdMlHlf5|-F~!hcjzDY}Wn Sd@C?{F?hQAxvX({ id, register, `${className || ''}` ].join(' '); + /** + * Returns placeholder text + */ + const placeholder = (): string => hasFile() ? t('app.shared.form_file_upload.edit') : t('app.shared.form_file_upload.browse'); + return ( -
-
- {hasFile() && ( -
- - - {file.attachment_name} - -
- )} +
+ {hasFile() && ( + {file.attachment_name} + )} +
{file?.id && file?.attachment_url && ( - + )} -
- - {!hasFile() && ( - {t('app.shared.form_file_upload.browse')} - )} - {hasFile() && ( - {t('app.shared.form_file_upload.edit')} - )} - - {hasFile() && ( - - - - )} + className="image-file-input" + accept={accept} + register={register} + formState={formState} + rules={rules} + disabled={disabled} + error={error} + warning={warning} + id={`${id}[attachment_files]`} + onChange={onFileSelected} + placeholder={placeholder()}/> + {hasFile() && + } className="is-main" /> + } +
); }; diff --git a/app/frontend/src/javascript/components/form/form-image-upload.tsx b/app/frontend/src/javascript/components/form/form-image-upload.tsx index 76bde2632..c2f2ce75e 100644 --- a/app/frontend/src/javascript/components/form/form-image-upload.tsx +++ b/app/frontend/src/javascript/components/form/form-image-upload.tsx @@ -8,7 +8,8 @@ import { FormInput } from '../form/form-input'; import { FormComponent } from '../../models/form-component'; import { AbstractFormItemProps } from './abstract-form-item'; import { FabButton } from '../base/fab-button'; -import noAvatar from '../../../../images/no_avatar.png'; +import noImage from '../../../../images/no_image.png'; +import { Trash } from 'phosphor-react'; export interface ImageType { id?: number, @@ -86,6 +87,11 @@ export const FormImageUpload = ({ id, register } } + /** + * Returns placeholder text + */ + const placeholder = (): string => hasImage() ? t('app.shared.form_image_upload.edit') : t('app.shared.form_image_upload.browse'); + // Compose classnames from props const classNames = [ `${className || ''}` @@ -94,25 +100,22 @@ export const FormImageUpload = ({ id, register return (
- +
-
- - {!hasImage() && {t('app.shared.form_image_upload.browse')}} - {hasImage() && {t('app.shared.form_image_upload.edit')}} - - - {hasImage() && } className="delete-image" />} +
+ + {hasImage() && } className="is-main" />}
); diff --git a/app/frontend/src/javascript/components/form/form-input.tsx b/app/frontend/src/javascript/components/form/form-input.tsx index 7d07b6950..8f103f943 100644 --- a/app/frontend/src/javascript/components/form/form-input.tsx +++ b/app/frontend/src/javascript/components/form/form-input.tsx @@ -67,6 +67,7 @@ export const FormInput = ({ id, re disabled={typeof disabled === 'function' ? disabled(id) : disabled} placeholder={placeholder} accept={accept} /> + {(type === 'file' && placeholder) && {placeholder}} {addOn && {addOn}} ); diff --git a/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx index bf6be5028..71df0ce11 100644 --- a/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx +++ b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx @@ -1,3 +1,4 @@ +import { PencilSimple, Trash } from 'phosphor-react'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ProductCategory } from '../../../models/product-category'; @@ -53,12 +54,12 @@ export const ManageProductCategory: React.FC = ({ pr ); case 'update': return (} + icon={} className="edit-btn" onClick={toggleModal} />); case 'delete': return (} + icon={} className="delete-btn" onClick={toggleModal} />); } diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx index 5f1143bd4..5cd7aab82 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -65,7 +65,7 @@ export const ProductCategoriesItem: React.FC = ({ pr
diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index e2139568f..2c9661a7e 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -17,6 +17,7 @@ import { FabAlert } from '../base/fab-alert'; import ProductCategoryAPI from '../../api/product-category'; import MachineAPI from '../../api/machine'; import ProductAPI from '../../api/product'; +import { Plus } from 'phosphor-react'; interface ProductFormProps { product: Product, @@ -194,6 +195,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc id="is_active" formState={formState} label={t('app.admin.store.product_form.is_show_in_store')} + tooltip={t('app.admin.store.product_form.active_price_info')} className='span-3' />
@@ -205,7 +207,6 @@ export const ProductForm: React.FC = ({ product, title, onSucc @@ -227,31 +228,43 @@ export const ProductForm: React.FC = ({ product, title, onSucc label={t('app.admin.store.product_form.quantity_min')} />
} +
-
+
-
-

{t('app.admin.store.product_form.product_images')}

- - - -
+
+

{t('app.admin.store.product_form.product_images')}

+ + + +
+
{output.product_images_attributes.map((image, i) => ( ))}
- {t('app.admin.store.product_form.add_product_image')} + }> + {t('app.admin.store.product_form.add_product_image')} +
+
+ +
+ +

{t('app.admin.store.product_form.assigning_category')}

@@ -261,20 +274,24 @@ export const ProductForm: React.FC = ({ product, title, onSucc id="product_category_id" formState={formState} label={t('app.admin.store.product_form.linking_product_to_category')} /> +
-
+
+

{t('app.admin.store.product_form.assigning_machines')}

+ control={control} + id="machine_ids" + formState={formState} /> +
-
+
+

{t('app.admin.store.product_form.product_description')}

@@ -283,25 +300,38 @@ export const ProductForm: React.FC = ({ product, title, onSucc paragraphTools={true} limit={1000} id="description" /> -
-

{t('app.admin.store.product_form.product_files')}

- - - - {output.product_files_attributes.map((file, i) => ( - - ))} - {t('app.admin.store.product_form.add_product_file')} +
+ +
+ +
+

{t('app.admin.store.product_form.product_files')}

+ + + +
+
+ {output.product_files_attributes.map((file, i) => ( + + ))} +
+ }> + {t('app.admin.store.product_form.add_product_file')} +
+
{t('app.admin.store.product_form.save')}
diff --git a/app/frontend/src/javascript/components/store/products-list.tsx b/app/frontend/src/javascript/components/store/products-list.tsx index c0254d8a6..676a15c07 100644 --- a/app/frontend/src/javascript/components/store/products-list.tsx +++ b/app/frontend/src/javascript/components/store/products-list.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { FabButton } from '../base/fab-button'; import { Product } from '../../models/product'; +import { PencilSimple, Trash } from 'phosphor-react'; interface ProductsListProps { products: Array, @@ -42,10 +43,10 @@ export const ProductsList: React.FC = ({ products, onEdit, on
- + - +
diff --git a/app/frontend/src/stylesheets/modules/base/fab-button.scss b/app/frontend/src/stylesheets/modules/base/fab-button.scss index 5bf2ece3f..4f825d1eb 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-button.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-button.scss @@ -1,23 +1,23 @@ .fab-button { - color: black; - background-color: #fbfbfb; - display: inline-block; + height: 38px; margin-bottom: 0; - font-weight: normal; - text-align: center; - white-space: nowrap; - vertical-align: middle; - touch-action: manipulation; - cursor: pointer; - background-image: none; - border: 1px solid #c9c9c9; padding: 6px 12px; + display: inline-flex; + align-items: center; + border: 1px solid #c9c9c9; + border-radius: 4px; + background-color: #fbfbfb; + background-image: none; font-size: 16px; line-height: 1.5; - border-radius: 4px; - user-select: none; + text-align: center; + font-weight: normal; text-decoration: none; - height: 38px; + color: black; + white-space: nowrap; + touch-action: manipulation; + cursor: pointer; + user-select: none; &:hover { background-color: #f2f2f2; @@ -45,32 +45,31 @@ &--icon { margin-right: 0.5em; + display: flex; } &--icon-only { display: flex; } // color variants - &.is-info { - border-color: var(--information); - background-color: var(--information); - color: var(--gray-soft-lightest); + @mixin colorVariant($color, $textColor) { + border-color: $color; + background-color: $color; + color: $textColor; &:hover { - border-color: var(--information); - background-color: var(--information); - color: var(--gray-soft-lightest); + border-color: $color; + background-color: $color; + color: $textColor; opacity: 0.75; } } + &.is-info { + @include colorVariant(var(--information), var(--gray-soft-lightest)); + } &.is-black { - border-color: var(--gray-hard-darkest); - background-color: var(--gray-hard-darkest); - color: var(--gray-soft-lightest); - &:hover { - border-color: var(--gray-hard-darkest); - background-color: var(--gray-hard-darkest); - color: var(--gray-soft-lightest); - opacity: 0.75; - } + @include colorVariant(var(--gray-hard-darkest), var(--gray-soft-lightest)); + } + &.is-main { + @include colorVariant(var(--main), var(--gray-soft-lightest)); } } diff --git a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss index d8c897b48..3715e2acb 100644 --- a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss +++ b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss @@ -180,4 +180,15 @@ margin-top: 0.4rem; color: var(--warning); } + + input[type='file'] { + opacity: 0; + width: 0; + height: 0; + margin: 0; + padding: 0; + } + .file-placeholder { + border: none; + } } diff --git a/app/frontend/src/stylesheets/modules/form/form-file-upload.scss b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss index 284bc29c4..454036469 100644 --- a/app/frontend/src/stylesheets/modules/form/form-file-upload.scss +++ b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss @@ -1,4 +1,30 @@ -.fileinput { +.form-file-upload { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + + .actions { + margin-left: auto; + display: flex; + align-items: center; + & > *:not(:first-child) { + margin-left: 1rem; + } + a { + display: flex; + } + + .image-file-input { + margin-bottom: 0; + } + } +} + +.nope-fileinput { display: table; border-collapse: separate; position: relative; diff --git a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss index fa35d9a26..eff5ea878 100644 --- a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss +++ b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss @@ -1,48 +1,41 @@ .form-image-upload { + &--small { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + } + &--large {} .image { - background-color: #fff; - border: 1px solid var(--gray-soft); - padding: 4px; - display: inline-block; - - &--small img { - width: 50px; - height: 50px; - } - - &--large img { - width: 180px; - height: 180px; - } - } - - .buttons { + flex-shrink: 0; display: flex; - justify-content: center; - margin-top: 20px; - - .select-button { - position: relative; - .image-file-input { - position: absolute; - z-index: 2; - opacity: 0; - top: 0; - left: 0; - } + object-fit: cover; + border-radius: var(--border-radius-sm); + overflow: hidden; + &--small { + width: 8rem; + height: 8rem; } - .delete-image { - background-color: var(--error); - color: white; + &--large {} + + img { + width: 100%; } } - &--large { - margin: 80px 40px; - } + .actions { + display: flex; + align-items: center; + & > *:not(:first-child) { + margin-left: 1rem; + } - &--small { - text-align: center; + .image-file-input { + margin: 0; + } } } diff --git a/app/frontend/src/stylesheets/modules/store/product-form.scss b/app/frontend/src/stylesheets/modules/store/product-form.scss index 7c45aa8a7..1dce888c7 100644 --- a/app/frontend/src/stylesheets/modules/store/product-form.scss +++ b/app/frontend/src/stylesheets/modules/store/product-form.scss @@ -1,4 +1,62 @@ -.product-images { - display: flex; - flex-wrap: wrap; -} +.product-form { + h4 { + margin: 0 0 2.4rem; + @include title-base; + } + hr { + margin: 4.8rem 0; + } + + .flex { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0 3.2rem; + & > * { + flex: 1 1 320px; + } + } + + .layout { + @media (max-width: 1023px) { + .span-3, + .span-7 { + flex-basis: 50%; + } + } + @media (max-width: 767px) { + flex-wrap: wrap; + } + } + + .price-data { + .layout { + align-items: center; + } + } + + .product-images { + display: flex; + flex-direction: column; + .list { + margin-bottom: 2.4rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 2.4rem; + } + button { margin-left: auto; } + } + + .product-documents { + display: flex; + flex-direction: column; + .list { + margin-bottom: 2.4rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(410px, 1fr)); + gap: 2.4rem; + } + button { margin-left: auto; } + } + +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/products.scss b/app/frontend/src/stylesheets/modules/store/products.scss index 05f4d59e6..6f85f825b 100644 --- a/app/frontend/src/stylesheets/modules/store/products.scss +++ b/app/frontend/src/stylesheets/modules/store/products.scss @@ -181,34 +181,4 @@ max-width: 1300px; padding-right: 1.6rem; padding-left: 1.6rem; - - .product-form { - .flex { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 0 3.2rem; - & > * { - flex: 1 1 320px; - } - } - - .layout { - @media (max-width: 1023px) { - .span-3, - .span-7 { - flex-basis: 50%; - } - } - @media (max-width: 767px) { - flex-wrap: wrap; - } - } - - .price-data { - .layout { - align-items: center; - } - } - } } diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index ee7e70f66..a3a77e0d8 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -100,7 +100,7 @@ en: delete_this_and_next: "This slot and the following" delete_all: "All slots" event_in_the_past: "Create a slot in the past" - confirm_create_event_in_the_past: "You are about to create a slot in the past. Are you sure you want to do this? Members will not be able to book this slot." + confirm_create_event_in_the_past: "You are about to create a slot in the past. Are you sure you want to do this? Members on the store? will not be able to book this slot." edit_event: "Edit the event" view_reservations: "View reservations" legend: "Legend" @@ -1944,6 +1944,7 @@ en: slug: "Name of URL" is_show_in_store: "Available in the store" is_active_price: "Activate the price" + active_price_info: "Is this product visible by the members on the store?" price_and_rule_of_selling_product: "Price and rule for selling the product" price: "Price of product" quantity_min: "Minimum number of items for the shopping cart" From f345fc2443054c928ecd25d420c26d4c7d50d5f1 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 3 Aug 2022 20:16:21 +0200 Subject: [PATCH 027/361] add is_main to product image --- app/controllers/api/products_controller.rb | 2 +- app/frontend/src/javascript/api/product.ts | 2 + .../components/form/form-image-upload.tsx | 36 +++++++++-- .../components/store/product-form.tsx | 64 +++++++++++++++++-- app/frontend/src/javascript/models/product.ts | 7 +- app/views/api/products/_product.json.jbuilder | 4 +- config/locales/app.shared.en.yml | 1 + config/locales/app.shared.fr.yml | 1 + .../20220803091913_add_is_main_to_assets.rb | 5 ++ db/schema.rb | 3 +- 10 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 db/migrate/20220803091913_add_is_main_to_assets.rb diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index b21e65be1..65a13eeb0 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -65,6 +65,6 @@ class API::ProductsController < API::ApiController :low_stock_alert, :low_stock_threshold, machine_ids: [], product_files_attributes: %i[id attachment _destroy], - product_images_attributes: %i[id attachment _destroy]) + product_images_attributes: %i[id attachment is_main _destroy]) end end diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts index c89a32642..a3c0a4e92 100644 --- a/app/frontend/src/javascript/api/product.ts +++ b/app/frontend/src/javascript/api/product.ts @@ -32,6 +32,7 @@ export default class ProductAPI { product.product_images_attributes?.forEach((image, i) => { if (image?.attachment_files && image?.attachment_files[0]) { data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]); + data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString()); } }); const res: AxiosResponse = await apiClient.post('/api/products', data, { @@ -73,6 +74,7 @@ export default class ProductAPI { if (image?._destroy) { data.set(`product[product_images_attributes][${i}][_destroy]`, image._destroy.toString()); } + data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString()); }); const res: AxiosResponse = await apiClient.patch(`/api/products/${product.id}`, data, { headers: { diff --git a/app/frontend/src/javascript/components/form/form-image-upload.tsx b/app/frontend/src/javascript/components/form/form-image-upload.tsx index 76bde2632..9214054b6 100644 --- a/app/frontend/src/javascript/components/form/form-image-upload.tsx +++ b/app/frontend/src/javascript/components/form/form-image-upload.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Path } from 'react-hook-form'; import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; @@ -13,7 +13,8 @@ import noAvatar from '../../../../images/no_avatar.png'; export interface ImageType { id?: number, attachment_name?: string, - attachment_url?: string + attachment_url?: string, + is_main?: boolean } interface FormImageUploadProps extends FormComponent, AbstractFormItemProps { @@ -21,19 +22,25 @@ interface FormImageUploadProps extends FormComponent defaultImage?: ImageType, accept?: string, size?: 'small' | 'large' + mainOption?: boolean, onFileChange?: (value: ImageType) => void, onFileRemove?: () => void, + onFileIsMain?: () => void, } /** * This component allows to upload image, in forms managed by react-hook-form. */ -export const FormImageUpload = ({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size }: FormImageUploadProps) => { +export const FormImageUpload = ({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size, onFileIsMain, mainOption = false }: FormImageUploadProps) => { const { t } = useTranslation('shared'); const [file, setFile] = useState(defaultImage); const [image, setImage] = useState(defaultImage.attachment_url); + useEffect(() => { + setFile(defaultImage); + }, [defaultImage]); + /** * Check if image is selected */ @@ -53,8 +60,13 @@ export const FormImageUpload = ({ id, register }; reader.readAsDataURL(f); setFile({ + ...file, attachment_name: f.name }); + setValue( + `${id}[attachment_name]` as Path, + f.name as UnpackNestedValue>> + ); setValue( `${id}[_destroy]` as Path, false as UnpackNestedValue>> @@ -80,12 +92,22 @@ export const FormImageUpload = ({ id, register null as UnpackNestedValue>> ); setFile(null); - setImage(null); if (typeof onFileRemove === 'function') { onFileRemove(); } } + /** + * Callback triggered when the user set the image is main + */ + function setMainImage () { + setValue( + `${id}[is_main]` as Path, + true as UnpackNestedValue>> + ); + onFileIsMain(); + } + // Compose classnames from props const classNames = [ `${className || ''}` @@ -114,6 +136,12 @@ export const FormImageUpload = ({ id, register {hasImage() && } className="delete-image" />}
+ {mainOption && +
+ + +
+ }
); }; diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index e2139568f..dc6fe7c70 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; +import { useForm, useWatch, Path } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import slugify from 'slugify'; import _ from 'lodash'; @@ -11,7 +11,7 @@ import { FormSelect } from '../form/form-select'; import { FormChecklist } from '../form/form-checklist'; import { FormRichText } from '../form/form-rich-text'; import { FormFileUpload } from '../form/form-file-upload'; -import { FormImageUpload } from '../form/form-image-upload'; +import { FormImageUpload, ImageType } from '../form/form-image-upload'; import { FabButton } from '../base/fab-button'; import { FabAlert } from '../base/fab-alert'; import ProductCategoryAPI from '../../api/product-category'; @@ -144,7 +144,9 @@ export const ProductForm: React.FC = ({ product, title, onSucc * Add new product image */ const addProductImage = () => { - setValue('product_images_attributes', output.product_images_attributes.concat({})); + setValue('product_images_attributes', output.product_images_attributes.concat({ + is_main: output.product_images_attributes.length === 0 + })); }; /** @@ -155,7 +157,59 @@ export const ProductForm: React.FC = ({ product, title, onSucc const productImage = output.product_images_attributes[i]; if (!productImage.id) { output.product_images_attributes.splice(i, 1); - setValue('product_images_attributes', output.product_images_attributes); + if (productImage.is_main) { + setValue('product_images_attributes', output.product_images_attributes.map((image, k) => { + if (k === 0) { + return { + ...image, + is_main: true + }; + } + return image; + })); + } else { + setValue('product_images_attributes', output.product_images_attributes); + } + } else { + if (productImage.is_main) { + let mainImage = false; + setValue('product_images_attributes', output.product_images_attributes.map((image, k) => { + if (i !== k && !mainImage) { + mainImage = true; + return { + ...image, + _destroy: i === k, + is_main: true + }; + } + return { + ...image, + _destroy: i === k + }; + })); + } + } + }; + }; + + /** + * Remove main image in others product images + */ + const handleSetMainImage = (i: number) => { + return () => { + if (output.product_images_attributes.length > 1) { + setValue('product_images_attributes', output.product_images_attributes.map((image, k) => { + if (i !== k) { + return { + ...image, + is_main: false + }; + } + return { + ...image, + is_main: true + }; + })); } }; }; @@ -246,7 +300,9 @@ export const ProductForm: React.FC = ({ product, title, onSucc setValue={setValue} formState={formState} className={image._destroy ? 'hidden' : ''} + mainOption={true} onFileRemove={handleRemoveProductImage(i)} + onFileIsMain={handleSetMainImage(i)} /> ))}
diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index 471e00248..57aa51809 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -27,7 +27,7 @@ export interface Product { attachment?: File, attachment_files?: FileList, attachment_name?: string, - attachment_url?: string + attachment_url?: string, _destroy?: boolean }>, product_images_attributes: Array<{ @@ -35,7 +35,8 @@ export interface Product { attachment?: File, attachment_files?: FileList, attachment_name?: string, - attachment_url?: string - _destroy?: boolean + attachment_url?: string, + _destroy?: boolean, + is_main?: boolean }> } diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder index 9b9a99336..37d9fca9b 100644 --- a/app/views/api/products/_product.json.jbuilder +++ b/app/views/api/products/_product.json.jbuilder @@ -1,6 +1,7 @@ # frozen_string_literal: true -json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold, :machine_ids +json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert, + :low_stock_threshold, :machine_ids json.amount product.amount / 100.0 if product.amount.present? json.product_files_attributes product.product_files do |f| json.id f.id @@ -11,4 +12,5 @@ json.product_images_attributes product.product_images do |f| json.id f.id json.attachment_name f.attachment_identifier json.attachment_url f.attachment_url + json.is_main f.is_main end diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 3fc1e941e..f5c3abaca 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -559,3 +559,4 @@ en: form_image_upload: browse: "Browse" edit: "Edit" + main_image: "Main image" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index 139728a02..6f2cfa3d8 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -559,3 +559,4 @@ fr: form_image_upload: browse: "Parcourir" edit: "Modifier" + main_image: "Visuel principal" diff --git a/db/migrate/20220803091913_add_is_main_to_assets.rb b/db/migrate/20220803091913_add_is_main_to_assets.rb new file mode 100644 index 000000000..3201a80a5 --- /dev/null +++ b/db/migrate/20220803091913_add_is_main_to_assets.rb @@ -0,0 +1,5 @@ +class AddIsMainToAssets < ActiveRecord::Migration[5.2] + def change + add_column :assets, :is_main, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 1222e9bcf..864a474d7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_07_20_135828) do +ActiveRecord::Schema.define(version: 2022_08_03_091913) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -70,6 +70,7 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.string "type" t.datetime "created_at" t.datetime "updated_at" + t.boolean "is_main" end create_table "auth_provider_mappings", id: :serial, force: :cascade do |t| From 640a4281986e7c0a95a44608ed4c014078b5a35b Mon Sep 17 00:00:00 2001 From: Du Peng Date: Thu, 4 Aug 2022 09:41:53 +0200 Subject: [PATCH 028/361] add size medium to product image --- .../components/form/form-image-upload.tsx | 2 +- .../modules/form/form-image-upload.scss | 17 +++++++++++++---- app/uploaders/product_image_uploader.rb | 4 ++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/app/frontend/src/javascript/components/form/form-image-upload.tsx b/app/frontend/src/javascript/components/form/form-image-upload.tsx index 9214054b6..5ee2ead72 100644 --- a/app/frontend/src/javascript/components/form/form-image-upload.tsx +++ b/app/frontend/src/javascript/components/form/form-image-upload.tsx @@ -21,7 +21,7 @@ interface FormImageUploadProps extends FormComponent setValue: UseFormSetValue, defaultImage?: ImageType, accept?: string, - size?: 'small' | 'large' + size?: 'small' | 'medium' | 'large' mainOption?: boolean, onFileChange?: (value: ImageType) => void, onFileRemove?: () => void, diff --git a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss index fa35d9a26..9dcc481e6 100644 --- a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss +++ b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss @@ -7,13 +7,18 @@ display: inline-block; &--small img { - width: 50px; - height: 50px; + width: 80px; + height: 80px; + } + + &--medium img { + width: 200px; + height: 200px; } &--large img { - width: 180px; - height: 180px; + width: 400px; + height: 400px; } } @@ -42,6 +47,10 @@ margin: 80px 40px; } + &--medium { + margin: 80px 40px; + } + &--small { text-align: center; } diff --git a/app/uploaders/product_image_uploader.rb b/app/uploaders/product_image_uploader.rb index a7830ab81..8b591a84b 100644 --- a/app/uploaders/product_image_uploader.rb +++ b/app/uploaders/product_image_uploader.rb @@ -49,6 +49,10 @@ class ProductImageUploader < CarrierWave::Uploader::Base process resize_to_fit: [700, 400] end + version :small do + process resize_to_fit: [400, 250] + end + # Add a white list of extensions which are allowed to be uploaded. # For images you might use something like this: def extension_whitelist From 927aa40182ac3d853d0c76413417ef8d27781f25 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 4 Aug 2022 10:44:31 +0200 Subject: [PATCH 029/361] Update image upload --- .../components/form/form-image-upload.tsx | 14 +-- .../components/store/product-form.tsx | 4 +- .../modules/form/form-file-upload.scss | 107 ------------------ .../modules/form/form-image-upload.scss | 26 +++-- .../modules/store/product-form.scss | 15 +-- 5 files changed, 27 insertions(+), 139 deletions(-) diff --git a/app/frontend/src/javascript/components/form/form-image-upload.tsx b/app/frontend/src/javascript/components/form/form-image-upload.tsx index 58772b823..dd3d5435d 100644 --- a/app/frontend/src/javascript/components/form/form-image-upload.tsx +++ b/app/frontend/src/javascript/components/form/form-image-upload.tsx @@ -22,7 +22,7 @@ interface FormImageUploadProps extends FormComponent setValue: UseFormSetValue, defaultImage?: ImageType, accept?: string, - size?: 'small' | 'medium' | 'large' + size?: 'small' | 'medium' | 'large', mainOption?: boolean, onFileChange?: (value: ImageType) => void, onFileRemove?: () => void, @@ -125,6 +125,12 @@ export const FormImageUpload = ({ id, register
+ {mainOption && + + } ({ id, register placeholder={placeholder()}/> {hasImage() && } className="is-main" />}
- {mainOption && -
- - -
- }
); }; diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index cafa7c5e6..e4be9c953 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useForm, useWatch, Path } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import slugify from 'slugify'; import _ from 'lodash'; @@ -11,7 +11,7 @@ import { FormSelect } from '../form/form-select'; import { FormChecklist } from '../form/form-checklist'; import { FormRichText } from '../form/form-rich-text'; import { FormFileUpload } from '../form/form-file-upload'; -import { FormImageUpload, ImageType } from '../form/form-image-upload'; +import { FormImageUpload } from '../form/form-image-upload'; import { FabButton } from '../base/fab-button'; import { FabAlert } from '../base/fab-alert'; import ProductCategoryAPI from '../../api/product-category'; diff --git a/app/frontend/src/stylesheets/modules/form/form-file-upload.scss b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss index 454036469..773f763d8 100644 --- a/app/frontend/src/stylesheets/modules/form/form-file-upload.scss +++ b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss @@ -23,110 +23,3 @@ } } } - -.nope-fileinput { - display: table; - border-collapse: separate; - position: relative; - margin-bottom: 9px; - - .filename-container { - align-items: center; - display: inline-flex; - float: left; - margin-bottom: 0; - position: relative; - width: 100%; - z-index: 2; - background-color: #fff; - background-image: none; - border: 1px solid #c4c4c4; - border-radius: 4px; - box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%); - height: 38px; - padding: 6px 12px; - transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; - color: #555; - font-size: 16px; - line-height: 1.5; - - .fileinput-filename { - vertical-align: bottom; - display: inline-block; - overflow: hidden; - margin-left: 10px; - } - - .file-download { - position: absolute; - right: 10px; - - i { - color: black; - } - } - } - .fileinput-button { - z-index: 1; - border: 1px solid #c4c4c4; - border-left: 0; - border-radius: 0 4px 4px 0; - position: relative; - vertical-align: middle; - background-color: #eee; - color: #555; - font-size: 16px; - font-weight: 400; - line-height: 1; - padding: 6px 12px; - text-align: center; - white-space: nowrap; - width: 1%; - display: table-cell; - background-image: none; - touch-action: manipulation; - overflow: hidden; - cursor: pointer; - border-collapse: separate; - border-spacing: 0; - - .form-input { - position: absolute; - z-index: 2; - opacity: 0; - top: 0; - left: 0; - } - - input[type=file] { - display: block; - cursor: pointer; - direction: ltr; - filter: alpha(opacity=0); - font-size: 23px; - height: 100%; - margin: 0; - opacity: 0; - position: absolute; - right: 0; - top: 0; - width: 100%; - } - } - - .fileinput-delete { - padding: 6px 12px; - font-size: 16px; - font-weight: 400; - line-height: 1; - color: #555555; - text-align: center; - background-color: #eeeeee; - border: 1px solid #c4c4c4; - border-radius: 4px; - width: 1%; - white-space: nowrap; - vertical-align: middle; - display: table-cell; - } -} diff --git a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss index f1696cc5a..68e7680fa 100644 --- a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss +++ b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss @@ -1,14 +1,18 @@ +@mixin base { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); +} .form-image-upload { - &--small { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1.6rem; - border: 1px solid var(--gray-soft-dark); - border-radius: var(--border-radius); - background-color: var(--gray-soft-lightest); + &--small, + &--medium, + &--large { + @include base; } - &--large {} .image { flex-shrink: 0; @@ -41,8 +45,10 @@ margin-left: 1rem; } + input[type="radio"] { margin-left: 0.5rem; } + .image-file-input { - margin: 0; + margin-bottom: 0; } } } diff --git a/app/frontend/src/stylesheets/modules/store/product-form.scss b/app/frontend/src/stylesheets/modules/store/product-form.scss index 1dce888c7..17f3adfbe 100644 --- a/app/frontend/src/stylesheets/modules/store/product-form.scss +++ b/app/frontend/src/stylesheets/modules/store/product-form.scss @@ -35,25 +35,14 @@ } } - .product-images { - display: flex; - flex-direction: column; - .list { - margin-bottom: 2.4rem; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: 2.4rem; - } - button { margin-left: auto; } - } - + .product-images, .product-documents { display: flex; flex-direction: column; .list { margin-bottom: 2.4rem; display: grid; - grid-template-columns: repeat(auto-fill, minmax(410px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); gap: 2.4rem; } button { margin-left: auto; } From 1b605b326528ddf14754239b2a4c91471a3a667e Mon Sep 17 00:00:00 2001 From: Du Peng Date: Thu, 4 Aug 2022 14:02:19 +0200 Subject: [PATCH 030/361] fix bug: product amount cannot update --- app/controllers/api/products_controller.rb | 16 ++-------------- app/services/product_service.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index 65a13eeb0..ca15bfafb 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -15,13 +15,7 @@ class API::ProductsController < API::ApiController def create authorize Product @product = Product.new(product_params) - if @product.amount.present? - if @product.amount.zero? - @product.amount = nil - else - @product.amount *= 100 - end - end + @product.amount = ProductService.amount_multiplied_by_hundred(@product.amount) if @product.save render status: :created else @@ -33,13 +27,7 @@ class API::ProductsController < API::ApiController authorize @product product_parameters = product_params - if product_parameters[:amount].present? - if product_parameters[:amount].zero? - product_parameters[:amount] = nil - else - product_parameters[:amount] *= 100 - end - end + product_parameters[:amount] = ProductService.amount_multiplied_by_hundred(product_parameters[:amount]) if @product.update(product_parameters) render status: :ok else diff --git a/app/services/product_service.rb b/app/services/product_service.rb index d31f61ae8..8c712bf35 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -5,4 +5,16 @@ class ProductService def self.list Product.all end + + # amount params multiplied by hundred + def self.amount_multiplied_by_hundred(amount) + if amount.present? + v = amount.to_f + + return nil if v.zero? + + return v * 100 + end + nil + end end From dc67d083950656fd815d819ba9f3a928f9d11e07 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 4 Aug 2022 15:19:34 +0200 Subject: [PATCH 031/361] Update color variables' names --- .../modules/form/abstract-form-item.scss | 22 +++++++++---------- .../modules/store/product-categories.scss | 2 +- .../supporting-documents-files.scss | 2 +- .../supporting-documents-validation.scss | 2 +- .../modules/user/avatar-input.scss | 2 +- .../src/stylesheets/variables/colors.scss | 20 ++++++++--------- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss index 3715e2acb..dd242cf0b 100644 --- a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss +++ b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss @@ -52,7 +52,7 @@ &.is-required &-header p::after { content: "*"; margin-left: 0.5ch; - color: var(--error); + color: var(--alert); } &-field { @@ -152,19 +152,19 @@ } } &.is-incorrect &-field { - border-color: var(--error); + border-color: var(--alert); .icon { - color: var(--error); - border-color: var(--error); - background-color: var(--error-lightest); + color: var(--alert); + border-color: var(--alert); + background-color: var(--alert-lightest); } } &.is-warned &-field { - border-color: var(--warning); + border-color: var(--notification); .icon { - color: var(--warning); - border-color: var(--warning); - background-color: var(--warning-lightest); + color: var(--notification); + border-color: var(--notification); + background-color: var(--notification-lightest); } } &.is-disabled &-field input, @@ -174,11 +174,11 @@ &-error { margin-top: 0.4rem; - color: var(--error); + color: var(--alert); } &-warning { margin-top: 0.4rem; - color: var(--warning); + color: var(--notification); } input[type='file'] { diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 1f3585856..d9a49db28 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -96,7 +96,7 @@ &:hover { opacity: 0.75; } } .edit-btn { background: var(--gray-hard-darkest); } - .delete-btn { background: var(--error); } + .delete-btn { background: var(--main); } } } } diff --git a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-files.scss b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-files.scss index 50b31ea98..bae4cfa58 100644 --- a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-files.scss +++ b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-files.scss @@ -12,7 +12,7 @@ .file-item { &.has-error { - color: var(--error); + color: var(--alert); } label { diff --git a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-validation.scss b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-validation.scss index a886005e0..e762d10d1 100644 --- a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-validation.scss +++ b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-validation.scss @@ -16,7 +16,7 @@ } .missing-file { - color: var(--error); + color: var(--alert); } } diff --git a/app/frontend/src/stylesheets/modules/user/avatar-input.scss b/app/frontend/src/stylesheets/modules/user/avatar-input.scss index 47242dae1..66be087b2 100644 --- a/app/frontend/src/stylesheets/modules/user/avatar-input.scss +++ b/app/frontend/src/stylesheets/modules/user/avatar-input.scss @@ -23,7 +23,7 @@ } } .delete-avatar { - background-color: var(--error); + background-color: var(--alert); color: white; } } diff --git a/app/frontend/src/stylesheets/variables/colors.scss b/app/frontend/src/stylesheets/variables/colors.scss index 50ba2ad5f..fff231c5c 100644 --- a/app/frontend/src/stylesheets/variables/colors.scss +++ b/app/frontend/src/stylesheets/variables/colors.scss @@ -28,11 +28,11 @@ --success-dark: #229051; --success-darkest: #155239; - --error-lightest: #FDF1F1; - --error-light: #EA8585; - --error: #DA3030; - --error-dark: #9F1D1D; - --error-darkest: #611818; + --alert-lightest: #FDF1F1; + --alert-light: #EA8585; + --alert: #DA3030; + --alert-dark: #9F1D1D; + --alert-darkest: #611818; --information-lightest: #EFF6FF; --information-light: #93C5FD; @@ -40,11 +40,11 @@ --information-dark: #1E3A8A; --information-darkest: #122354; - --warning-lightest: #FFFCF4; - --warning-light: #FAE29F; - --warning: #D6AE47; - --warning-dark: #8C6D1F; - --warning-darkest: #5C4813; + --notification-lightest: #FFFCF4; + --notification-light: #FAE29F; + --notification: #D6AE47; + --notification-dark: #8C6D1F; + --notification-darkest: #5C4813; --main-text-color: black; --secondary-text-color: black; From d118d045c66c92d1823ca347f7ff26887ba285af Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 4 Aug 2022 16:24:52 +0200 Subject: [PATCH 032/361] Style list items --- .../components/store/products-list.tsx | 54 +++++++++++++++++-- .../javascript/components/store/products.tsx | 5 +- .../stylesheets/modules/store/products.scss | 53 ++++++++++++++++-- .../src/stylesheets/variables/typography.scss | 6 +++ config/locales/app.admin.en.yml | 7 +++ 5 files changed, 117 insertions(+), 8 deletions(-) diff --git a/app/frontend/src/javascript/components/store/products-list.tsx b/app/frontend/src/javascript/components/store/products-list.tsx index 676a15c07..f800aa864 100644 --- a/app/frontend/src/javascript/components/store/products-list.tsx +++ b/app/frontend/src/javascript/components/store/products-list.tsx @@ -1,7 +1,10 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; +import FormatLib from '../../lib/format'; import { FabButton } from '../base/fab-button'; import { Product } from '../../models/product'; import { PencilSimple, Trash } from 'phosphor-react'; +import noImage from '../../../../images/no_image.png'; interface ProductsListProps { products: Array, @@ -13,6 +16,19 @@ interface ProductsListProps { * This component shows a list of all Products */ export const ProductsList: React.FC = ({ products, onEdit, onDelete }) => { + console.log('products: ', products); + const { t } = useTranslation('admin'); + + /** + * TODO, document this method + */ + const thumbnail = (id: number) => { + const image = products + ?.find(p => p.id === id) + .product_images_attributes + .find(att => att.is_main); + return image; + }; /** * Init the process of editing the given product */ @@ -31,15 +47,47 @@ export const ProductsList: React.FC = ({ products, onEdit, on }; }; + /** + * Returns CSS class from stock status + */ + const statusColor = (product: Product) => { + if (product.stock.external === 0 && product.stock.internal === 0) { + return 'out-of-stock'; + } + if (product.low_stock_alert) { + return 'low'; + } + }; + return ( <> {products.map((product) => ( -
+
- + {/* TODO: image size version ? */} +

{product.name}

-
+
+ + {product.is_active + ? t('app.admin.store.products_list.visible') + : t('app.admin.store.products_list.hidden') + } + +
+ {t('app.admin.store.products_list.stock.internal')} +

{product.stock.internal}

+
+
+ {t('app.admin.store.products_list.stock.external')} +

{product.stock.external}

+
+
+

{FormatLib.price(product.amount)}

+ / {t('app.admin.store.products_list.unit')} +
+
diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 6d27af5b1..0192b4245 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -7,6 +7,7 @@ import { FabButton } from '../base/fab-button'; import { ProductsList } from './products-list'; import { Product } from '../../models/product'; import ProductAPI from '../../api/product'; +import { X } from 'phosphor-react'; declare const Application: IApplication; @@ -91,11 +92,11 @@ const Products: React.FC = ({ onSuccess, onError }) => {

feature name

- +

long feature name

- +
Date: Fri, 5 Aug 2022 18:38:54 +0200 Subject: [PATCH 033/361] (wip) Products list filters --- .../store/categories/product-categories.tsx | 2 +- .../components/store/products-list.tsx | 11 +- .../javascript/components/store/products.tsx | 208 +++++++++++++++++- app/frontend/src/stylesheets/application.scss | 4 +- .../modules/store/products-filters.scss | 86 ++++++++ .../modules/store/products-list.scss | 181 +++++++++++++++ .../stylesheets/modules/store/products.scss | 149 ------------- config/locales/app.admin.en.yml | 12 + 8 files changed, 486 insertions(+), 167 deletions(-) create mode 100644 app/frontend/src/stylesheets/modules/store/products-filters.scss create mode 100644 app/frontend/src/stylesheets/modules/store/products-list.scss diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx index 25a7dc617..c0f50f2e8 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -54,7 +54,7 @@ const ProductCategories: React.FC = ({ onSuccess, onErro */ const refreshCategories = () => { ProductCategoryAPI.index().then(data => { - // Translate ProductCategory.position to array index + // Map product categories by position const sortedCategories = data .filter(c => !c.parent_id) .sort((a, b) => a.position - b.position); diff --git a/app/frontend/src/javascript/components/store/products-list.tsx b/app/frontend/src/javascript/components/store/products-list.tsx index f800aa864..fbac5fb92 100644 --- a/app/frontend/src/javascript/components/store/products-list.tsx +++ b/app/frontend/src/javascript/components/store/products-list.tsx @@ -16,7 +16,6 @@ interface ProductsListProps { * This component shows a list of all Products */ export const ProductsList: React.FC = ({ products, onEdit, onDelete }) => { - console.log('products: ', products); const { t } = useTranslation('admin'); /** @@ -83,10 +82,12 @@ export const ProductsList: React.FC = ({ products, onEdit, on {t('app.admin.store.products_list.stock.external')}

{product.stock.external}

-
-

{FormatLib.price(product.amount)}

- / {t('app.admin.store.products_list.unit')} -
+ {product.amount && +
+

{FormatLib.price(product.amount)}

+ / {t('app.admin.store.products_list.unit')} +
+ }
diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 0192b4245..ef7acea50 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -1,13 +1,20 @@ +// TODO: Remove next eslint-disable +/* eslint-disable @typescript-eslint/no-unused-vars */ import React, { useState, useEffect } from 'react'; +import { useImmer } from 'use-immer'; import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; import { Loader } from '../base/loader'; import { IApplication } from '../../models/application'; +import { Product } from '../../models/product'; +import { ProductCategory } from '../../models/product-category'; import { FabButton } from '../base/fab-button'; import { ProductsList } from './products-list'; -import { Product } from '../../models/product'; import ProductAPI from '../../api/product'; -import { X } from 'phosphor-react'; +import ProductCategoryAPI from '../../api/product-category'; +import MachineAPI from '../../api/machine'; +import { CaretDown, X } from 'phosphor-react'; +import Switch from 'react-switch'; declare const Application: IApplication; @@ -23,13 +30,45 @@ const Products: React.FC = ({ onSuccess, onError }) => { const { t } = useTranslation('admin'); const [products, setProducts] = useState>([]); + const [filteredProductsList, setFilteredProductList] = useImmer>([]); + const [filterVisible, setFilterVisible] = useState(false); + const [clearFilters, setClearFilters] = useState(false); + const [filters, setFilters] = useImmer(initFilters); + const [productCategories, setProductCategories] = useState([]); + const [machines, setMachines] = useState([]); useEffect(() => { ProductAPI.index().then(data => { setProducts(data); + setFilteredProductList(data); }); }, []); + useEffect(() => { + ProductCategoryAPI.index().then(data => { + // Map product categories by position + const sortedCategories = data + .filter(c => !c.parent_id) + .sort((a, b) => a.position - b.position); + const childrenCategories = data + .filter(c => typeof c.parent_id === 'number') + .sort((a, b) => b.position - a.position); + childrenCategories.forEach(c => { + const parentIndex = sortedCategories.findIndex(i => i.id === c.parent_id); + sortedCategories.splice(parentIndex + 1, 0, c); + }); + setProductCategories(sortedCategories); + }).catch(onError); + MachineAPI.index({ disabled: false }).then(data => { + setMachines(buildChecklistOptions(data)); + }).catch(onError); + }, []); + + useEffect(() => { + applyFilters(); + setClearFilters(false); + }, [filterVisible, clearFilters]); + /** * Goto edit product page */ @@ -58,6 +97,71 @@ const Products: React.FC = ({ onSuccess, onError }) => { window.location.href = '/#!/admin/store/products/new'; }; + /** + * Filter: toggle hidden products visibility + */ + const toggleVisible = (checked: boolean) => { + setFilterVisible(checked); + }; + + /** + * Filter: by categories + */ + const handleSelectCategory = (c: ProductCategory, checked) => { + let list = [...filters.categories]; + const children = productCategories + .filter(el => el.parent_id === c.id) + .map(el => el.id); + const siblings = productCategories + .filter(el => el.parent_id === c.parent_id && el.parent_id !== null); + + if (checked) { + list.push(c.id); + if (children.length) { + const unic = Array.from(new Set([...list, ...children])); + list = [...unic]; + } + if (siblings.length && siblings.every(el => list.includes(el.id))) { + list.push(siblings[0].parent_id); + } + } else { + list.splice(list.indexOf(c.id), 1); + if (c.parent_id && list.includes(c.parent_id)) { + list.splice(list.indexOf(c.parent_id), 1); + } + if (children.length) { + children.forEach(child => { + list.splice(list.indexOf(child), 1); + }); + } + } + setFilters(draft => { + return { ...draft, categories: list }; + }); + }; + + /** + * Apply filters + */ + const applyFilters = () => { + let updatedList = [...products]; + if (filterVisible) { + updatedList = updatedList.filter(p => p.is_active); + } + if (filters.categories.length) { + updatedList = updatedList.filter(p => filters.categories.includes(p.product_category_id)); + } + setFilteredProductList(updatedList); + }; + + /** + * Clear filters + */ + const clearAllFilters = () => { + setFilters(initFilters); + setClearFilters(true); + }; + return (
@@ -69,38 +173,79 @@ const Products: React.FC = ({ onSuccess, onError }) => {
-

Filtrer

+

{t('app.admin.store.products.filter')}

- Clear + {t('app.admin.store.products.filter_clear')}
+
+
+ +
{t('app.admin.store.products.filter_categories')} +
+
+
+ {productCategories.map(pc => ( + + ))} +
+ {t('app.admin.store.products.filter_apply')} +
+
+
+ +
{t('app.admin.store.products.filter_machines')} +
+
+ Lorem ipsum dolor, sit amet consectetur adipisicing elit. Minus saepe aperiam autem eum magni nihil odio totam enim similique error! Est veritatis illum adipisci aspernatur sit nulla voluptate. Exercitationem, totam! + {t('app.admin.store.products.filter_apply')} +
+
+
-

Result count: {products.length}

+

{t('app.admin.store.products.result_count')}{filteredProductsList.length}

-
+
-

Display options:

+

{t('app.admin.store.products.display_options')}

+
- +

feature name

- +

long feature name

- +
@@ -119,3 +264,44 @@ const ProductsWrapper: React.FC = ({ onSuccess, onError }) => { }; Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError'])); + +/** + * Option format, expected by checklist + */ +type checklistOption = { value: number, label: string }; + +/** + * Convert the provided array of items to the checklist format + */ +const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array => { + return items.map(t => { + return { value: t.id, label: t.name }; + }); +}; + +const initFilters: Filters = { + categories: [], + machines: [], + keywords: [], + internalStock: { + from: 0, + to: null + }, + externalStock: { + from: 0, + to: null + } +}; + +interface Stock { + from: number, + to: number +} + +interface Filters { + categories: number[], + machines: number[], + keywords: string[], + internalStock: Stock, + externalStock: Stock +} diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 704204551..889bdf3fd 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -91,6 +91,9 @@ @import "modules/store/_utilities"; @import "modules/store/manage-product-category"; @import "modules/store/product-categories"; +@import "modules/store/product-form"; +@import "modules/store/products-filters"; +@import "modules/store/products-list"; @import "modules/store/products"; @import "modules/subscriptions/free-extend-modal"; @import "modules/subscriptions/renew-modal"; @@ -104,7 +107,6 @@ @import "modules/user/gender-input"; @import "modules/user/user-profile-form"; @import "modules/user/user-validation"; -@import "modules/store/product-form"; @import "modules/abuses"; @import "modules/cookies"; diff --git a/app/frontend/src/stylesheets/modules/store/products-filters.scss b/app/frontend/src/stylesheets/modules/store/products-filters.scss new file mode 100644 index 000000000..034a6eb4d --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/products-filters.scss @@ -0,0 +1,86 @@ +.products-filters { + padding-top: 1.6rem; + border-top: 1px solid var(--gray-soft-dark); + + .accordion { + &-item:not(:last-of-type) { + margin-bottom: 1.6rem; + border-bottom: 1px solid var(--gray-soft-darkest); + } + &-item { + position: relative; + padding-bottom: 1.6rem; + + & > input[type=checkbox] { + position: absolute; + width: 100%; + z-index: 1; + opacity: 0; + cursor: pointer; + } + & > input[type=checkbox]:checked ~ .content { + max-height: 0; + } + & > input[type=checkbox]:checked ~ header svg { + transform: rotateZ(180deg); + } + header { + width: 100%; + padding: 0; + display: flex; + justify-content: space-between; + align-items: center; + background: none; + border: none; + @include text-base(600); + svg { transition: transform 250ms ease-in-out; } + } + .content { + max-height: 24rem; + padding-top: 1.6rem; + display: flex; + flex-direction: column; + align-items: stretch; + transition: max-height 500ms ease-in-out; + overflow: hidden; + + .list { + overflow: hidden auto; + label { + display: flex; + align-items: center; + input[type=checkbox] { margin: 0 0.8rem 0 0; } + p { margin: 0; } + &.offset { margin-left: 1.6rem; } + } + } + button { + margin-top: 0.8rem; + justify-content: center; + } + } + } + } +} + +// Custom scrollbar +.scrollbar { + &::-webkit-scrollbar-track + { + border-radius: 6px; + background-color: #d9d9d9; + } + + &::-webkit-scrollbar + { + width: 12px; + background-color: #ffffff; + } + + &::-webkit-scrollbar-thumb + { + border-radius: 6px; + background-color: #2d2d2d; + border: 2px solid #d9d9d9 + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/products-list.scss b/app/frontend/src/stylesheets/modules/store/products-list.scss new file mode 100644 index 000000000..788a8e57d --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/products-list.scss @@ -0,0 +1,181 @@ +.products-list { + .status { + padding: 1.6rem 2.4rem; + display: flex; + justify-content: space-between; + background-color: var(--gray-soft); + border-radius: var(--border-radius); + p { margin: 0; } + + .count { + p { + display: flex; + align-items: center; + @include text-sm; + span { + margin-left: 1.6rem; + @include text-lg(600); + } + } + } + + .display { + display: flex; + align-items: center; + & > *:not(:first-child) { + margin-left: 2rem; + padding-left: 2rem; + border-left: 1px solid var(--gray-hard-darkest); + } + + .sort { + display: flex; + align-items: center; + } + + .visibility { + display: flex; + align-items: center; + label { + margin: 0; + display: flex; + align-items: center; + font-weight: 400; + cursor: pointer; + span { + margin-right: 1rem; + } + } + } + } + } + + .features { + margin: 2.4rem 0 1.6rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1.6rem 2.4rem; + &-item { + padding-left: 1.6rem; + display: flex; + align-items: center; + background-color: var(--information-light); + border-radius: 100px; + color: var(--information-dark); + overflow: hidden; + p { margin: 0; } + button { + width: 3.2rem; + height: 3.2rem; + margin-left: 0.8rem; + display: flex; + align-items: center; + background: none; + border: none; + } + } + } + + &-item { + --status-color: var(--gray-hard-darkest); + &.low { --status-color: var(--alert-light); } + &.out-of-stock { --status-color: var(--alert); } + + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.6rem 0.8rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + &.out-of-stock { border-color: var(--status-color); } + &:not(:first-child) { + margin-top: 1.6rem; + } + + .itemInfo { + min-width: 20ch; + flex: 1; + display: flex; + align-items: center; + + &-thumbnail { + width: 4.8rem; + height: 4.8rem; + margin-right: 1.6rem; + object-fit: cover; + border-radius: var(--border-radius); + background-color: var(--gray-soft); + } + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + } + .details { + display: grid; + grid-template-columns: 120px repeat(2, minmax(min-content, 120px)) 120px; + justify-items: center; + align-items: center; + gap: 1.6rem; + margin-left: auto; + margin-right: 4rem; + p { + margin: 0; + @include text-base(600); + } + + .visibility { + justify-self: center; + padding: 0.4rem 0.8rem; + display: flex; + align-items: center; + background-color: var(--gray-soft-light); + border-radius: var(--border-radius); + &::before { + flex-shrink: 0; + margin-right: 1rem; + content: ""; + width: 1rem; + height: 1rem; + background-color: var(--gray-hard); + border-radius: 50%; + } + &.is-active::before { + background-color: var(--success); + } + } + .stock { + display: flex; + flex-direction: column; + color: var(--status-color); + span { @include text-xs; } + } + .price { + justify-self: flex-end; + } + } + + .actions { + display: flex; + justify-content: flex-end; + align-items: center; + .manage { + overflow: hidden; + display: flex; + border-radius: var(--border-radius-sm); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-btn {background: var(--gray-hard-darkest) } + .delete-btn {background: var(--main) } + } + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/products.scss b/app/frontend/src/stylesheets/modules/store/products.scss index f3a7e9331..4db7b4c12 100644 --- a/app/frontend/src/stylesheets/modules/store/products.scss +++ b/app/frontend/src/stylesheets/modules/store/products.scss @@ -72,155 +72,6 @@ .layout { align-items: flex-start; } - - &-filters { - padding-top: 1.6rem; - border-top: 1px solid var(--gray-soft-dark); - } - - &-list { - .status { - padding: 1.6rem 2.4rem; - display: flex; - justify-content: space-between; - background-color: var(--gray-soft); - border-radius: var(--border-radius); - p { margin: 0; } - .count { - p { - display: flex; - align-items: center; - @include text-sm; - span { - margin-left: 1.6rem; - @include text-lg(600); - } - } - } - } - .features { - margin: 2.4rem 0 1.6rem; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 1.6rem 2.4rem; - &-item { - padding-left: 1.6rem; - display: flex; - align-items: center; - background-color: var(--information-light); - border-radius: 100px; - color: var(--information-dark); - p { margin: 0; } - button { - width: 3.2rem; - height: 3.2rem; - display: flex; - align-items: center; - background: none; - border: none; - } - } - } - - &-item { - --status-color: var(--gray-hard-darkest); - &.low { --status-color: var(--alert-light); } - &.out-of-stock { --status-color: var(--alert); } - - display: flex; - justify-content: space-between; - align-items: center; - padding: 1.6rem 0.8rem; - border: 1px solid var(--gray-soft-dark); - border-radius: var(--border-radius); - background-color: var(--gray-soft-lightest); - &.out-of-stock { border-color: var(--status-color); } - &:not(:first-child) { - margin-top: 1.6rem; - } - - .itemInfo { - min-width: 20ch; - display: flex; - align-items: center; - - &-thumbnail { - width: 4.8rem; - height: 4.8rem; - margin-right: 1.6rem; - object-fit: cover; - border-radius: var(--border-radius); - background-color: var(--gray-soft); - } - &-name { - margin: 0; - @include text-base; - font-weight: 600; - color: var(--gray-hard-darkest); - } - } - .details { - display: grid; - grid-template-columns: 140px repeat(3, minmax(120px, 1fr)); - justify-items: flex-start; - align-items: center; - gap: 1rem; - margin-left: auto; - margin-right: 4rem; - p { - margin: 0; - @include text-base(600); - } - - .visibility { - justify-self: center; - padding: 0.4rem 0.8rem; - display: flex; - align-items: center; - background-color: var(--gray-soft-light); - border-radius: var(--border-radius); - &::before { - flex-shrink: 0; - margin-right: 1rem; - content: ""; - width: 1rem; - height: 1rem; - background-color: var(--gray-hard); - border-radius: 50%; - } - &.is-active::before { - background-color: var(--success); - } - } - .stock { - display: flex; - flex-direction: column; - color: var(--status-color); - span { @include text-xs; } - } - } - - .actions { - display: flex; - justify-content: flex-end; - align-items: center; - .manage { - overflow: hidden; - display: flex; - border-radius: var(--border-radius-sm); - button { - @include btn; - border-radius: 0; - color: var(--gray-soft-lightest); - &:hover { opacity: 0.75; } - } - .edit-btn {background: var(--gray-hard-darkest) } - .delete-btn {background: var(--main) } - } - } - } - } } .new-product, diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 4c3a97195..c8fedb8c1 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1933,6 +1933,18 @@ en: create_a_product: "Create a product" successfully_deleted: "The product has been successfully deleted" unable_to_delete: "Unable to delete the product: " + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "By categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + filter_stock: "By stock status" + filter_stock_from: "From" + filter_stock_to: "to" + result_count: "Result count:" + display_options: "Display options:" + visible_only: "Visible products only" products_list: visible: "visible" hidden: "hidden" From 1cef45e3d75b05f8bbcddbeddc1bd8a7a5cad206 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Fri, 5 Aug 2022 15:25:51 +0200 Subject: [PATCH 034/361] add product stock mouvements --- app/controllers/api/products_controller.rb | 3 ++- app/frontend/src/javascript/models/product.ts | 13 ++++++++++++- app/models/product.rb | 5 ++++- app/models/product_stock_movement.rb | 18 ++++++++++++++++++ app/views/api/products/_product.json.jbuilder | 8 ++++++++ ...805083431_create_product_stock_movements.rb | 16 ++++++++++++++++ db/schema.rb | 15 ++++++++++++++- 7 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 app/models/product_stock_movement.rb create mode 100644 db/migrate/20220805083431_create_product_stock_movements.rb diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index ca15bfafb..2c5835f66 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -53,6 +53,7 @@ class API::ProductsController < API::ApiController :low_stock_alert, :low_stock_threshold, machine_ids: [], product_files_attributes: %i[id attachment _destroy], - product_images_attributes: %i[id attachment is_main _destroy]) + product_images_attributes: %i[id attachment is_main _destroy], + product_stock_movements_attributes: %i[id quantity reason stock_type _destroy]) end end diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index 57aa51809..fc951c1e5 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -1,3 +1,5 @@ +import { TDateISO } from '../typings/date-iso'; + export enum StockType { internal = 'internal', external = 'external' @@ -38,5 +40,14 @@ export interface Product { attachment_url?: string, _destroy?: boolean, is_main?: boolean - }> + }>, + product_stock_movements_attributes: Array<{ + id?: number, + quantity?: number, + reason?: string, + stock_type?: string, + remaining_stock?: number, + date?: TDateISO, + _destroy?: boolean + }>, } diff --git a/app/models/product.rb b/app/models/product.rb index 7bc4087a7..946a3c454 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -12,7 +12,10 @@ class Product < ApplicationRecord has_many :product_images, as: :viewable, dependent: :destroy accepts_nested_attributes_for :product_images, allow_destroy: true, reject_if: :all_blank - validates_numericality_of :amount, greater_than: 0, allow_nil: true + has_many :product_stock_movements, dependent: :destroy + accepts_nested_attributes_for :product_stock_movements, allow_destroy: true, reject_if: :all_blank + + validates :amount, numericality: { greater_than: 0, allow_nil: true } scope :active, -> { where(is_active: true) } end diff --git a/app/models/product_stock_movement.rb b/app/models/product_stock_movement.rb new file mode 100644 index 000000000..6d5623f2f --- /dev/null +++ b/app/models/product_stock_movement.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# ProductStockMovement is a model for record the movements of product's stock +class ProductStockMovement < ApplicationRecord + belongs_to :product + + ALL_STOCK_TYPES = %w[internal external].freeze + enum stock_type: ALL_STOCK_TYPES.zip(ALL_STOCK_TYPES).to_h + + ALL_REASONS = %w[incoming_stock returned_by_customer cancelled_by_customer sold missing_from_inventory damaged].freeze + enum reason: ALL_REASONS.zip(ALL_REASONS).to_h + + validates :stock_type, presence: true + validates :stock_type, inclusion: { in: ALL_STOCK_TYPES } + + validates :reason, presence: true + validates :reason, inclusion: { in: ALL_REASONS } +end diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder index 37d9fca9b..a6fe177cf 100644 --- a/app/views/api/products/_product.json.jbuilder +++ b/app/views/api/products/_product.json.jbuilder @@ -14,3 +14,11 @@ json.product_images_attributes product.product_images do |f| json.attachment_url f.attachment_url json.is_main f.is_main end +json.product_stock_movements_attributes product.product_stock_movements do |s| + json.id s.id + json.quantity s.quantity + json.reason s.reason + json.stock_type s.stock_type + json.remaining_stock s.remaining_stock + json.date s.date +end diff --git a/db/migrate/20220805083431_create_product_stock_movements.rb b/db/migrate/20220805083431_create_product_stock_movements.rb new file mode 100644 index 000000000..d7c7af564 --- /dev/null +++ b/db/migrate/20220805083431_create_product_stock_movements.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateProductStockMovements < ActiveRecord::Migration[5.2] + def change + create_table :product_stock_movements do |t| + t.belongs_to :product, foreign_key: true + t.integer :quantity + t.string :reason + t.string :stock_type + t.integer :remaining_stock + t.datetime :date + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 864a474d7..ca31162a3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_08_03_091913) do +ActiveRecord::Schema.define(version: 2022_08_05_083431) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -597,6 +597,18 @@ ActiveRecord::Schema.define(version: 2022_08_03_091913) do t.index ["parent_id"], name: "index_product_categories_on_parent_id" end + create_table "product_stock_movements", force: :cascade do |t| + t.bigint "product_id" + t.integer "quantity" + t.string "reason" + t.string "stock_type" + t.integer "remaining_stock" + t.datetime "date" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["product_id"], name: "index_product_stock_movements_on_product_id" + end + create_table "products", force: :cascade do |t| t.string "name" t.string "slug" @@ -1135,6 +1147,7 @@ ActiveRecord::Schema.define(version: 2022_08_03_091913) do add_foreign_key "prepaid_packs", "groups" add_foreign_key "prices", "groups" add_foreign_key "prices", "plans" + add_foreign_key "product_stock_movements", "products" add_foreign_key "products", "product_categories" add_foreign_key "project_steps", "projects" add_foreign_key "project_users", "projects" From 0ee6521d3c3a14676564b5e54f48493f6b4b56d0 Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 9 Aug 2022 11:36:52 +0200 Subject: [PATCH 035/361] Filter by machines --- .../javascript/components/store/products.tsx | 37 ++++++++++++++++--- .../modules/store/products-filters.scss | 11 +++++- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index ef7acea50..7bd04691b 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -15,6 +15,7 @@ import ProductCategoryAPI from '../../api/product-category'; import MachineAPI from '../../api/machine'; import { CaretDown, X } from 'phosphor-react'; import Switch from 'react-switch'; +import { Machine } from '../../models/machine'; declare const Application: IApplication; @@ -140,6 +141,19 @@ const Products: React.FC = ({ onSuccess, onError }) => { }); }; + /** + * Filter: by machines + */ + const handleSelectMachine = (m: checklistOption, checked) => { + const list = [...filters.machines]; + checked + ? list.push(m.value) + : list.splice(list.indexOf(m.value), 1); + setFilters(draft => { + return { ...draft, machines: list }; + }); + }; + /** * Apply filters */ @@ -151,6 +165,11 @@ const Products: React.FC = ({ onSuccess, onError }) => { if (filters.categories.length) { updatedList = updatedList.filter(p => filters.categories.includes(p.product_category_id)); } + if (filters.machines.length) { + updatedList = updatedList.filter(p => { + return p.machine_ids.find(m => filters.machines.includes(m)); + }); + } setFilteredProductList(updatedList); }; @@ -182,26 +201,34 @@ const Products: React.FC = ({ onSuccess, onError }) => {
{t('app.admin.store.products.filter_categories')} -
+
{productCategories.map(pc => ( ))}
{t('app.admin.store.products.filter_apply')}
+
{t('app.admin.store.products.filter_machines')} -
+
- Lorem ipsum dolor, sit amet consectetur adipisicing elit. Minus saepe aperiam autem eum magni nihil odio totam enim similique error! Est veritatis illum adipisci aspernatur sit nulla voluptate. Exercitationem, totam! - {t('app.admin.store.products.filter_apply')} +
+ {machines.map(m => ( + + ))} +
+ {t('app.admin.store.products.filter_apply')}
diff --git a/app/frontend/src/stylesheets/modules/store/products-filters.scss b/app/frontend/src/stylesheets/modules/store/products-filters.scss index 034a6eb4d..b24d06949 100644 --- a/app/frontend/src/stylesheets/modules/store/products-filters.scss +++ b/app/frontend/src/stylesheets/modules/store/products-filters.scss @@ -47,10 +47,19 @@ .list { overflow: hidden auto; label { + margin: 0 0.8rem 0 0; + padding: 0.6rem; display: flex; align-items: center; + &:hover { + background-color: var(--gray-soft-light); + cursor: pointer; + } input[type=checkbox] { margin: 0 0.8rem 0 0; } - p { margin: 0; } + p { + margin: 0; + @include text-base; + } &.offset { margin-left: 1.6rem; } } } From 7ca3955c1d24f99e634fe8b43f3e2826062e7339 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 13 Aug 2022 19:23:56 +0200 Subject: [PATCH 036/361] Filter and sort products --- .../javascript/components/store/products.tsx | 156 ++++++++++++++---- .../modules/store/products-list.scss | 7 +- config/locales/app.admin.en.yml | 5 + 3 files changed, 130 insertions(+), 38 deletions(-) diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 7bd04691b..492e03aab 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -15,7 +15,7 @@ import ProductCategoryAPI from '../../api/product-category'; import MachineAPI from '../../api/machine'; import { CaretDown, X } from 'phosphor-react'; import Switch from 'react-switch'; -import { Machine } from '../../models/machine'; +import Select from 'react-select'; declare const Application: IApplication; @@ -23,6 +23,11 @@ interface ProductsProps { onSuccess: (message: string) => void, onError: (message: string) => void, } +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ + type selectOption = { value: number, label: string }; /** * This component shows all Products and filter @@ -32,11 +37,14 @@ const Products: React.FC = ({ onSuccess, onError }) => { const [products, setProducts] = useState>([]); const [filteredProductsList, setFilteredProductList] = useImmer>([]); + const [features, setFeatures] = useImmer(initFilters); const [filterVisible, setFilterVisible] = useState(false); - const [clearFilters, setClearFilters] = useState(false); const [filters, setFilters] = useImmer(initFilters); + const [sortOption, setSortOption] = useState(0); + const [clearFilters, setClearFilters] = useState(false); const [productCategories, setProductCategories] = useState([]); const [machines, setMachines] = useState([]); + const [update, setUpdate] = useState(false); useEffect(() => { ProductAPI.index().then(data => { @@ -68,7 +76,8 @@ const Products: React.FC = ({ onSuccess, onError }) => { useEffect(() => { applyFilters(); setClearFilters(false); - }, [filterVisible, clearFilters]); + setUpdate(false); + }, [filterVisible, clearFilters, update === true]); /** * Goto edit product page @@ -108,27 +117,27 @@ const Products: React.FC = ({ onSuccess, onError }) => { /** * Filter: by categories */ - const handleSelectCategory = (c: ProductCategory, checked) => { + const handleSelectCategory = (c: ProductCategory, checked, instantUpdate?) => { let list = [...filters.categories]; const children = productCategories - .filter(el => el.parent_id === c.id) - .map(el => el.id); + .filter(el => el.parent_id === c.id); const siblings = productCategories .filter(el => el.parent_id === c.parent_id && el.parent_id !== null); if (checked) { - list.push(c.id); + list.push(c); if (children.length) { - const unic = Array.from(new Set([...list, ...children])); - list = [...unic]; + const unique = Array.from(new Set([...list, ...children])); + list = [...unique]; } - if (siblings.length && siblings.every(el => list.includes(el.id))) { - list.push(siblings[0].parent_id); + if (siblings.length && siblings.every(el => list.includes(el))) { + list.push(productCategories.find(p => p.id === siblings[0].parent_id)); } } else { - list.splice(list.indexOf(c.id), 1); - if (c.parent_id && list.includes(c.parent_id)) { - list.splice(list.indexOf(c.parent_id), 1); + list.splice(list.indexOf(c), 1); + const parent = productCategories.find(p => p.id === c.parent_id); + if (c.parent_id && list.includes(parent)) { + list.splice(list.indexOf(parent), 1); } if (children.length) { children.forEach(child => { @@ -139,19 +148,33 @@ const Products: React.FC = ({ onSuccess, onError }) => { setFilters(draft => { return { ...draft, categories: list }; }); + if (instantUpdate) { + setUpdate(true); + } }; /** * Filter: by machines */ - const handleSelectMachine = (m: checklistOption, checked) => { + const handleSelectMachine = (m: checklistOption, checked, instantUpdate?) => { const list = [...filters.machines]; checked - ? list.push(m.value) - : list.splice(list.indexOf(m.value), 1); + ? list.push(m) + : list.splice(list.indexOf(m), 1); setFilters(draft => { return { ...draft, machines: list }; }); + if (instantUpdate) { + setUpdate(true); + } + }; + + /** + * Display option: sorting + */ + const handleSorting = (value: number) => { + setSortOption(value); + setUpdate(true); }; /** @@ -159,17 +182,32 @@ const Products: React.FC = ({ onSuccess, onError }) => { */ const applyFilters = () => { let updatedList = [...products]; + let tags = initFilters; if (filterVisible) { updatedList = updatedList.filter(p => p.is_active); } + if (filters.categories.length) { - updatedList = updatedList.filter(p => filters.categories.includes(p.product_category_id)); + updatedList = updatedList.filter(p => filters.categories + .map(fc => fc.id) + .includes(p.product_category_id)); } + tags = { ...tags, categories: [...filters.categories] }; + if (filters.machines.length) { updatedList = updatedList.filter(p => { - return p.machine_ids.find(m => filters.machines.includes(m)); + return p.machine_ids.find(pmId => filters.machines + .map(fmId => fmId.value) + .includes(pmId)); }); } + tags = { ...tags, machines: [...filters.machines] }; + + if (sortOption >= 0) { + updatedList = sortProductsList(updatedList, sortOption); + } + + setFeatures(tags); setFilteredProductList(updatedList); }; @@ -181,6 +219,33 @@ const Products: React.FC = ({ onSuccess, onError }) => { setClearFilters(true); }; + /** + * Creates sorting options to the react-select format + */ + const buildOptions = (): Array => { + return [ + { value: 0, label: t('app.admin.store.products.sort.name_az') }, + { value: 1, label: t('app.admin.store.products.sort.name_za') } + // { value: 2, label: t('app.admin.store.products.sort.price_low') }, + // { value: 3, label: t('app.admin.store.products.sort.price_high') } + ]; + }; + /** + * Sorts products list + */ + const sortProductsList = (list: Product[], option: number): Product[] => { + switch (option) { + case 0: + return list.sort((a, b) => a.name.localeCompare(b.name)); + case 1: + return list.sort((a, b) => b.name.localeCompare(a.name)); + case 2: + return list.sort((a, b) => a.amount - b.amount); + case 3: + return list.sort((a, b) => b.amount - a.amount); + } + }; + return (
@@ -206,7 +271,7 @@ const Products: React.FC = ({ onSuccess, onError }) => {
{productCategories.map(pc => ( ))} @@ -223,7 +288,7 @@ const Products: React.FC = ({ onSuccess, onError }) => {
{machines.map(m => ( ))} @@ -241,10 +306,12 @@ const Products: React.FC = ({ onSuccess, onError }) => {

{t('app.admin.store.products.display_options')}

- + -
{t('app.admin.store.products.filter_categories')} -
+
{productCategories.map(pc => ( @@ -278,12 +288,13 @@ const Products: React.FC = ({ onSuccess, onError }) => {
{t('app.admin.store.products.filter_apply')}
-
+ -
- -
{t('app.admin.store.products.filter_machines')} -
+
{machines.map(m => ( @@ -295,7 +306,7 @@ const Products: React.FC = ({ onSuccess, onError }) => {
{t('app.admin.store.products.filter_apply')}
-
+
diff --git a/app/frontend/src/stylesheets/modules/store/products-filters.scss b/app/frontend/src/stylesheets/modules/store/products-filters.scss index b24d06949..352b5c913 100644 --- a/app/frontend/src/stylesheets/modules/store/products-filters.scss +++ b/app/frontend/src/stylesheets/modules/store/products-filters.scss @@ -10,6 +10,10 @@ &-item { position: relative; padding-bottom: 1.6rem; + &.collapsed { + .content { max-height: 0; } + header svg { transform: rotateZ(180deg); } + } & > input[type=checkbox] { position: absolute; @@ -33,6 +37,7 @@ background: none; border: none; @include text-base(600); + cursor: pointer; svg { transition: transform 250ms ease-in-out; } } .content { From 605b3ec92c2f842aeb086f52d78d51328b7d43b8 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 15 Aug 2022 14:52:09 +0200 Subject: [PATCH 038/361] (wip) drag and drop categories --- .../categories/product-categories-item.tsx | 37 +++++----- .../categories/product-categories-tree.tsx | 72 +++++++++++++------ .../modules/store/product-categories.scss | 1 + 3 files changed, 73 insertions(+), 37 deletions(-) diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx index 5cd7aab82..76e603246 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -5,12 +5,12 @@ import { ProductCategory } from '../../../models/product-category'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { ManageProductCategory } from './manage-product-category'; -import { CaretDown, DotsSixVertical } from 'phosphor-react'; +import { ArrowElbowDownRight, ArrowElbowLeftUp, CaretDown, DotsSixVertical } from 'phosphor-react'; interface ProductCategoriesItemProps { productCategories: Array, category: ProductCategory, - offset: boolean, + offset: 'up' | 'down' | null, collapsed?: boolean, handleCollapse?: (id: number) => void, status: 'child' | 'single' | 'parent', @@ -39,13 +39,16 @@ export const ProductCategoriesItem: React.FC = ({ pr return (
- {(status === 'child' || offset) && -
+ {((isDragging && offset) || status === 'child') && +
+ {(offset === 'down') && } + {(offset === 'up') && } +
} -
+
{status === 'parent' &&
-
} @@ -53,16 +56,18 @@ export const ProductCategoriesItem: React.FC = ({ pr [count]
-
- - -
+ {!isDragging && +
+ + +
+ }
+
+
+

long feature name

+ +
+
+ +
+ {products.map((product) => ( + + ))} +
+
+
+
+ ); +}; + +const StoreWrapper: React.FC = ({ onError }) => { + return ( + + + + ); +}; + +Application.Components.component('store', react2angular(StoreWrapper, ['onError'])); diff --git a/app/frontend/src/javascript/controllers/main_nav.js b/app/frontend/src/javascript/controllers/main_nav.js index ead43fda0..44e50c473 100644 --- a/app/frontend/src/javascript/controllers/main_nav.js +++ b/app/frontend/src/javascript/controllers/main_nav.js @@ -53,6 +53,12 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm linkIcon: 'tags', class: 'reserve-event-link' }, + { + state: 'app.public.store', + linkText: 'app.public.common.fablab_store', + linkIcon: 'cart-plus', + class: 'store-link' + }, { class: 'menu-spacer' }, { state: 'app.public.projects_list', diff --git a/app/frontend/src/javascript/controllers/products.js b/app/frontend/src/javascript/controllers/products.js new file mode 100644 index 000000000..90a4c6473 --- /dev/null +++ b/app/frontend/src/javascript/controllers/products.js @@ -0,0 +1,42 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('ShowProductController', ['$scope', 'CSRF', 'growl', '$transition$', + function ($scope, CSRF, growl, $transition$) { + /* PRIVATE SCOPE */ + + /* PUBLIC SCOPE */ + $scope.productSlug = $transition$.params().slug; + + /** + * Callback triggered in case of error + */ + $scope.onError = (message) => { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/controllers/store.js b/app/frontend/src/javascript/controllers/store.js new file mode 100644 index 000000000..fef6b91c2 --- /dev/null +++ b/app/frontend/src/javascript/controllers/store.js @@ -0,0 +1,41 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('StoreController', ['$scope', 'CSRF', 'growl', '$state', + function ($scope, CSRF, growl, $state) { + /* PRIVATE SCOPE */ + + /* PUBLIC SCOPE */ + + /** + * Callback triggered in case of error + */ + $scope.onError = (message) => { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index fc951c1e5..27665b281 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -1,4 +1,9 @@ import { TDateISO } from '../typings/date-iso'; +import { ApiFilter } from './api'; + +export interface ProductIndexFilter extends ApiFilter { + is_active: boolean, +} export enum StockType { internal = 'internal', diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index df21a17b5..987283682 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -600,6 +600,28 @@ angular.module('application.router', ['ui.router']) } }) + // store + .state('app.public.store', { + url: '/store', + views: { + 'main@': { + templateUrl: '/store/index.html', + controller: 'StoreController' + } + } + }) + + // show product + .state('app.public.product_show', { + url: '/store/p/:slug', + views: { + 'main@': { + templateUrl: '/products/show.html', + controller: 'ShowProductController' + } + } + }) + // --- namespace /admin/... --- // calendar .state('app.admin.calendar', { diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 704204551..e34092967 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -92,6 +92,7 @@ @import "modules/store/manage-product-category"; @import "modules/store/product-categories"; @import "modules/store/products"; +@import "modules/store/store"; @import "modules/subscriptions/free-extend-modal"; @import "modules/subscriptions/renew-modal"; @import "modules/supporting-documents/supporting-documents-files"; diff --git a/app/frontend/src/stylesheets/modules/store/store.scss b/app/frontend/src/stylesheets/modules/store/store.scss new file mode 100644 index 000000000..e31405226 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/store.scss @@ -0,0 +1,170 @@ +.store { + margin: 0 auto; + padding-bottom: 6rem; + + .back-btn { + margin: 2.4rem 0; + padding: 0.4rem 0.8rem; + display: inline-flex; + align-items: center; + background-color: var(--gray-soft-darkest); + border-radius: var(--border-radius-sm); + color: var(--gray-soft-lightest); + i { margin-right: 0.8rem; } + + &:hover { + background-color: var(--gray-hard-lightest); + cursor: pointer; + } + } + + header { + padding: 2.4rem 0; + display: flex; + justify-content: space-between; + align-items: center; + .grpBtn { + display: flex; + & > *:not(:first-child) { margin-left: 2.4rem; } + } + h2 { + margin: 0; + @include title-lg; + color: var(--gray-hard-darkest) !important; + } + h3 { + margin: 0; + @include text-lg(600); + color: var(--gray-hard-darkest) !important; + } + } + + .layout { + display: flex; + align-items: flex-end; + gap: 0 3.2rem; + .span-7 { flex: 1 1 70%; } + .span-3 { flex: 1 1 30%; } + } + + .main-action-btn { + background-color: var(--main); + color: var(--gray-soft-lightest); + border: none; + &:hover { opacity: 0.75; } + } + + .main-actions { + display: flex; + justify-content: center; + align-items: center; + & > *:not(:first-child) { + margin-left: 1.6rem; + } + } +} + +.store { + max-width: 1600px; + + .layout { + align-items: flex-start; + } + + &-filters { + } + + &-products-list { + .products { + display: flex; + flex-wrap: wrap; + } + + .status { + padding: 1.6rem 2.4rem; + display: flex; + justify-content: space-between; + background-color: var(--gray-soft); + border-radius: var(--border-radius); + p { margin: 0; } + .count { + p { + display: flex; + align-items: center; + @include text-sm; + span { + margin-left: 1.6rem; + @include text-lg(600); + } + } + } + } + .features { + margin: 2.4rem 0 1.6rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1.6rem 2.4rem; + &-item { + padding-left: 1.6rem; + display: flex; + align-items: center; + background-color: var(--information-light); + border-radius: 100px; + color: var(--information-dark); + p { margin: 0; } + button { + width: 3.2rem; + height: 3.2rem; + background: none; + border: none; + } + } + } + } + + &-product-item { + padding: 1rem 1.8rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + + margin-right: 1.6rem; + + .itemInfo-image { + align-items: center; + + img { + width: 19.8rem; + height: 14.8rem; + object-fit: cover; + border-radius: var(--border-radius); + background-color: var(--gray-soft); + } + } + .itemInfo-name { + margin: 1rem 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + + .actions { + display: flex; + align-items: center; + .manage { + overflow: hidden; + display: flex; + border-radius: var(--border-radius-sm); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-btn {background: var(--gray-hard-darkest) } + .delete-btn {background: var(--error) } + } + } + } +} diff --git a/app/frontend/templates/products/show.html b/app/frontend/templates/products/show.html new file mode 100644 index 000000000..ecee5f9c3 --- /dev/null +++ b/app/frontend/templates/products/show.html @@ -0,0 +1,24 @@ +
+
+
+
+ +
+
+
+
+

{{ 'app.public.store.fablab_store' }}

+
+
+ +
+
+
+
+
+
+ + +
+ +
diff --git a/app/frontend/templates/store/index.html b/app/frontend/templates/store/index.html new file mode 100644 index 000000000..ef8bf3ff7 --- /dev/null +++ b/app/frontend/templates/store/index.html @@ -0,0 +1,24 @@ +
+
+
+
+ +
+
+
+
+

{{ 'app.public.store.fablab_store' }}

+
+
+ +
+
+
+
+
+
+ + +
+ +
diff --git a/app/models/product.rb b/app/models/product.rb index 946a3c454..0a3748ac9 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -2,6 +2,9 @@ # Product is a model for the merchandise hold information of product in store class Product < ApplicationRecord + extend FriendlyId + friendly_id :name, use: :slugged + belongs_to :product_category has_and_belongs_to_many :machines @@ -15,6 +18,7 @@ class Product < ApplicationRecord has_many :product_stock_movements, dependent: :destroy accepts_nested_attributes_for :product_stock_movements, allow_destroy: true, reject_if: :all_blank + validates :name, :slug, presence: true validates :amount, numericality: { greater_than: 0, allow_nil: true } scope :active, -> { where(is_active: true) } diff --git a/app/services/product_service.rb b/app/services/product_service.rb index 8c712bf35..1bfcdde75 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -2,8 +2,13 @@ # Provides methods for Product class ProductService - def self.list - Product.all + def self.list(filters) + products = Product.includes(:product_images) + if filters[:is_active].present? + state = filters[:disabled] == 'false' ? [nil, false] : true + products = products.where(is_active: state) + end + products end # amount params multiplied by hundred diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder index a6fe177cf..8e70bb0be 100644 --- a/app/views/api/products/_product.json.jbuilder +++ b/app/views/api/products/_product.json.jbuilder @@ -1,7 +1,8 @@ # frozen_string_literal: true -json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert, +json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold, :machine_ids +json.description sanitize(product.description) json.amount product.amount / 100.0 if product.amount.present? json.product_files_attributes product.product_files do |f| json.id f.id diff --git a/app/views/api/products/index.json.jbuilder b/app/views/api/products/index.json.jbuilder index bc58aeb30..10be62f81 100644 --- a/app/views/api/products/index.json.jbuilder +++ b/app/views/api/products/index.json.jbuilder @@ -1,5 +1,13 @@ # frozen_string_literal: true json.array! @products do |product| - json.partial! 'api/products/product', product: product + json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :machine_ids, + :low_stock_threshold + json.amount product.amount / 100.0 if product.amount.present? + json.product_images_attributes product.product_images do |f| + json.id f.id + json.attachment_name f.attachment_identifier + json.attachment_url f.attachment_url + json.is_main f.is_main + end end diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index aae52cf2f..0a5c733cd 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -43,6 +43,7 @@ en: projects_gallery: "Projects gallery" subscriptions: "Subscriptions" public_calendar: "Calendar" + fablab_store: "Fablab Store" #left menu (admin) trainings_monitoring: "Trainings" manage_the_calendar: "Calendar" @@ -373,6 +374,16 @@ en: characteristics: "Characteristics" files_to_download: "Files to download" projects_using_the_space: "Projects using the space" + #public store + store: + fablab_store: "FabLab Store" + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + store_product_item: + available: "Available" + limited_stock: "Limited stock" + add: "Add" + store_product: + unexpected_error_occurred: "An unexpected error occurred. Please try again later." tour: conclusion: title: "Thank you for your attention" diff --git a/config/locales/app.public.fr.yml b/config/locales/app.public.fr.yml index 0fdb83993..7265b859d 100644 --- a/config/locales/app.public.fr.yml +++ b/config/locales/app.public.fr.yml @@ -43,6 +43,7 @@ fr: projects_gallery: "Galerie de projets" subscriptions: "Abonnements" public_calendar: "Agenda" + fablab_store: "Boutique Fablab" #left menu (admin) trainings_monitoring: "Formations" manage_the_calendar: "Agenda" @@ -373,6 +374,16 @@ fr: characteristics: "Caractéristiques" files_to_download: "Fichiers à télécharger" projects_using_the_space: "Projets utilisant l'espace" + #public store + store: + fablab_store: "Boutique FabLab" + unexpected_error_occurred: "Une erreur inattendue s'est produite. Veuillez réessayer ultérieurement." + store_product_item: + available: "Disponible" + limited_stock: "Stock limité" + add: "Ajouter" + store_product: + unexpected_error_occurred: "An unexpected error occurred. Please try again later." tour: conclusion: title: "Merci de votre attention" From ab800a519ff15049eba3e8bfaee1f813d485a62a Mon Sep 17 00:00:00 2001 From: Du Peng Date: Fri, 19 Aug 2022 19:59:13 +0200 Subject: [PATCH 040/361] store cart --- app/controllers/api/cart_controller.rb | 54 +++++++++++++++ app/exceptions/cart/inactive_product_error.rb | 5 ++ app/exceptions/cart/out_stock_error.rb | 5 ++ app/frontend/src/javascript/api/cart.ts | 25 +++++++ .../javascript/components/cart/store-cart.tsx | 65 +++++++++++++++++++ .../components/store/store-product-item.tsx | 16 ++++- .../src/javascript/components/store/store.tsx | 5 +- .../src/javascript/controllers/cart.js | 41 ++++++++++++ app/frontend/src/javascript/hooks/use-cart.ts | 29 +++++++++ app/frontend/src/javascript/lib/cart-token.ts | 23 +++++++ app/frontend/src/javascript/models/order.ts | 21 ++++++ app/frontend/src/javascript/router.js | 11 ++++ app/frontend/templates/cart/index.html | 19 ++++++ app/models/order.rb | 12 ++++ app/models/order_item.rb | 9 +++ app/policies/cart_policy.rb | 14 ++++ app/services/cart/add_item_service.rb | 25 +++++++ app/services/cart/create_service.rb | 19 ++++++ app/services/cart/remove_item_service.rb | 17 +++++ app/services/cart/set_quantity_service.rb | 22 +++++++ app/services/generate_token_service.rb | 21 ++++++ app/views/api/orders/_order.json.jbuilder | 14 ++++ app/views/api/orders/show.json.jbuilder | 3 + config/locales/app.public.en.yml | 2 + config/locales/app.public.fr.yml | 2 + config/routes.rb | 7 +- db/migrate/20220808161314_create_orders.rb | 14 ++++ .../20220818160821_create_order_items.rb | 16 +++++ db/schema.rb | 29 ++++++++- package.json | 1 + yarn.lock | 5 ++ 31 files changed, 546 insertions(+), 5 deletions(-) create mode 100644 app/controllers/api/cart_controller.rb create mode 100644 app/exceptions/cart/inactive_product_error.rb create mode 100644 app/exceptions/cart/out_stock_error.rb create mode 100644 app/frontend/src/javascript/api/cart.ts create mode 100644 app/frontend/src/javascript/components/cart/store-cart.tsx create mode 100644 app/frontend/src/javascript/controllers/cart.js create mode 100644 app/frontend/src/javascript/hooks/use-cart.ts create mode 100644 app/frontend/src/javascript/lib/cart-token.ts create mode 100644 app/frontend/src/javascript/models/order.ts create mode 100644 app/frontend/templates/cart/index.html create mode 100644 app/models/order.rb create mode 100644 app/models/order_item.rb create mode 100644 app/policies/cart_policy.rb create mode 100644 app/services/cart/add_item_service.rb create mode 100644 app/services/cart/create_service.rb create mode 100644 app/services/cart/remove_item_service.rb create mode 100644 app/services/cart/set_quantity_service.rb create mode 100644 app/services/generate_token_service.rb create mode 100644 app/views/api/orders/_order.json.jbuilder create mode 100644 app/views/api/orders/show.json.jbuilder create mode 100644 db/migrate/20220808161314_create_orders.rb create mode 100644 db/migrate/20220818160821_create_order_items.rb diff --git a/app/controllers/api/cart_controller.rb b/app/controllers/api/cart_controller.rb new file mode 100644 index 000000000..d97c21a78 --- /dev/null +++ b/app/controllers/api/cart_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# API Controller for manage user's cart +class API::CartController < API::ApiController + before_action :current_order + before_action :ensure_order, except: %i[create] + + def create + authorize :cart, :create? + @order = current_order if current_order.present? + @order ||= Cart::CreateService.new.call(current_user) + render 'api/orders/show' + end + + def add_item + authorize @current_order, policy_class: CartPolicy + @order = Cart::AddItemService.new.call(@current_order, orderable, cart_params[:quantity]) + render 'api/orders/show' + end + + def remove_item + authorize :cart, policy_class: CartPolicy + @order = Cart::RemoveItemService.new.call(@current_order, orderable) + render 'api/orders/show' + end + + def set_quantity + authorize :cart, policy_class: CartPolicy + @order = Cart::SetQuantityService.new.call(@current_order, orderable, cart_params[:quantity]) + render 'api/orders/show' + end + + private + + def order_token + request.headers['X-Fablab-Order-Token'] || cart_params[:order_token] + end + + def current_order + @current_order = Order.find_by(token: order_token) + end + + def ensure_order + raise ActiveRecord::RecordNotFound if @current_order.nil? + end + + def orderable + Product.find(cart_params[:orderable_id]) + end + + def cart_params + params.permit(:order_token, :orderable_id, :quantity) + end +end diff --git a/app/exceptions/cart/inactive_product_error.rb b/app/exceptions/cart/inactive_product_error.rb new file mode 100644 index 000000000..dff476462 --- /dev/null +++ b/app/exceptions/cart/inactive_product_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when the product is out of stock +class Cart::InactiveProductError < StandardError +end diff --git a/app/exceptions/cart/out_stock_error.rb b/app/exceptions/cart/out_stock_error.rb new file mode 100644 index 000000000..effaaad54 --- /dev/null +++ b/app/exceptions/cart/out_stock_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when the product is out of stock +class Cart::OutStockError < StandardError +end diff --git a/app/frontend/src/javascript/api/cart.ts b/app/frontend/src/javascript/api/cart.ts new file mode 100644 index 000000000..4601de322 --- /dev/null +++ b/app/frontend/src/javascript/api/cart.ts @@ -0,0 +1,25 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { Order } from '../models/order'; + +export default class CartAPI { + static async create (token?: string): Promise { + const res: AxiosResponse = await apiClient.post('/api/cart', { order_token: token }); + return res?.data; + } + + static async addItem (order: Order, orderableId: number, quantity: number): Promise { + const res: AxiosResponse = await apiClient.put('/api/cart/add_item', { order_token: order.token, orderable_id: orderableId, quantity }); + return res?.data; + } + + static async removeItem (order: Order, orderableId: number): Promise { + const res: AxiosResponse = await apiClient.put('/api/cart/remove_item', { order_token: order.token, orderable_id: orderableId }); + return res?.data; + } + + static async setQuantity (order: Order, orderableId: number, quantity: number): Promise { + const res: AxiosResponse = await apiClient.put('/api/cart/set_quantity', { order_token: order.token, orderable_id: orderableId, quantity }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx new file mode 100644 index 000000000..5ca920d38 --- /dev/null +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { FabButton } from '../base/fab-button'; +import useCart from '../../hooks/use-cart'; +import FormatLib from '../../lib/format'; +import CartAPI from '../../api/cart'; + +declare const Application: IApplication; + +interface StoreCartProps { + onError: (message: string) => void, +} + +/** + * This component shows user's cart + */ +const StoreCart: React.FC = ({ onError }) => { + const { t } = useTranslation('public'); + + const { loading, cart, setCart } = useCart(); + + /** + * Remove the product from cart + */ + const removeProductFromCart = (item) => { + return (e: React.BaseSyntheticEvent) => { + e.preventDefault(); + e.stopPropagation(); + CartAPI.removeItem(cart, item.orderable_id).then(data => { + setCart(data); + }); + }; + }; + + return ( +
+ {loading &&

loading

} + {cart && cart.order_items_attributes.map(item => ( +
+
{item.orderable_name}
+
{FormatLib.price(item.amount)}
+
{item.quantity}
+
{FormatLib.price(item.quantity * item.amount)}
+ + {t('app.public.store_cart.remove_item')} + +
+ ))} + {cart &&

{cart.amount}

} +
+ ); +}; + +const StoreCartWrapper: React.FC = ({ onError }) => { + return ( + + + + ); +}; + +Application.Components.component('storeCart', react2angular(StoreCartWrapper, ['onError'])); diff --git a/app/frontend/src/javascript/components/store/store-product-item.tsx b/app/frontend/src/javascript/components/store/store-product-item.tsx index 5ae94ec1c..fc7aa00f8 100644 --- a/app/frontend/src/javascript/components/store/store-product-item.tsx +++ b/app/frontend/src/javascript/components/store/store-product-item.tsx @@ -3,16 +3,19 @@ import { useTranslation } from 'react-i18next'; import _ from 'lodash'; import { FabButton } from '../base/fab-button'; import { Product } from '../../models/product'; +import { Order } from '../../models/order'; import FormatLib from '../../lib/format'; +import CartAPI from '../../api/cart'; interface StoreProductItemProps { product: Product, + cart: Order, } /** * This component shows a product item in store */ -export const StoreProductItem: React.FC = ({ product }) => { +export const StoreProductItem: React.FC = ({ product, cart }) => { const { t } = useTranslation('public'); /** @@ -36,6 +39,15 @@ export const StoreProductItem: React.FC = ({ product }) = return {t('app.public.store_product_item.available')}; }; + /** + * Add the product to cart + */ + const addProductToCart = (e: React.BaseSyntheticEvent) => { + e.preventDefault(); + e.stopPropagation(); + CartAPI.addItem(cart, product.id, 1); + }; + /** * Goto show product page */ @@ -54,7 +66,7 @@ export const StoreProductItem: React.FC = ({ product }) =
{FormatLib.price(product.amount)}
{productStockStatus(product)} - + {t('app.public.store_product_item.add')}
diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx index 0ecdbf58a..78339cf0a 100644 --- a/app/frontend/src/javascript/components/store/store.tsx +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -7,6 +7,7 @@ import { FabButton } from '../base/fab-button'; import { Product } from '../../models/product'; import ProductAPI from '../../api/product'; import { StoreProductItem } from './store-product-item'; +import useCart from '../../hooks/use-cart'; declare const Application: IApplication; @@ -20,6 +21,8 @@ interface StoreProps { const Store: React.FC = ({ onError }) => { const { t } = useTranslation('public'); + const { cart } = useCart(); + const [products, setProducts] = useState>([]); useEffect(() => { @@ -68,7 +71,7 @@ const Store: React.FC = ({ onError }) => {
{products.map((product) => ( - + ))}
diff --git a/app/frontend/src/javascript/controllers/cart.js b/app/frontend/src/javascript/controllers/cart.js new file mode 100644 index 000000000..e8e753d14 --- /dev/null +++ b/app/frontend/src/javascript/controllers/cart.js @@ -0,0 +1,41 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('CartController', ['$scope', 'CSRF', 'growl', '$state', + function ($scope, CSRF, growl, $state) { + /* PRIVATE SCOPE */ + + /* PUBLIC SCOPE */ + + /** + * Callback triggered in case of error + */ + $scope.onError = (message) => { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/hooks/use-cart.ts b/app/frontend/src/javascript/hooks/use-cart.ts new file mode 100644 index 000000000..587647ff2 --- /dev/null +++ b/app/frontend/src/javascript/hooks/use-cart.ts @@ -0,0 +1,29 @@ +import { useState, useEffect } from 'react'; +import { Order } from '../models/order'; +import CartAPI from '../api/cart'; +import { getCartToken, setCartToken } from '../lib/cart-token'; + +export default function useCart () { + const [cart, setCart] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function createCart () { + const currentCartToken = getCartToken(); + const data = await CartAPI.create(currentCartToken); + setCart(data); + setLoading(false); + setCartToken(data.token); + } + setLoading(true); + try { + createCart(); + } catch (e) { + setLoading(false); + setError(e); + } + }, []); + + return { loading, cart, error, setCart }; +} diff --git a/app/frontend/src/javascript/lib/cart-token.ts b/app/frontend/src/javascript/lib/cart-token.ts new file mode 100644 index 000000000..9d7973faf --- /dev/null +++ b/app/frontend/src/javascript/lib/cart-token.ts @@ -0,0 +1,23 @@ +import Cookies from 'js-cookie'; + +export const cartCookieName = 'fablab_cart_token'; +export const cartCookieExpire = 7; + +export const getCartToken = () => + Cookies.get(cartCookieName); + +export const setCartToken = (cartToken: string) => { + const cookieOptions = { + expires: cartCookieExpire + }; + + Cookies.set( + cartCookieName, + cartToken, + cookieOptions + ); +}; + +export const removeCartToken = () => { + Cookies.remove(cartCookieName); +}; diff --git a/app/frontend/src/javascript/models/order.ts b/app/frontend/src/javascript/models/order.ts new file mode 100644 index 000000000..f36393777 --- /dev/null +++ b/app/frontend/src/javascript/models/order.ts @@ -0,0 +1,21 @@ +import { TDateISO } from '../typings/date-iso'; + +export interface Order { + id: number, + token: string, + statistic_profile_id?: number, + operator_id?: number, + reference?: string, + state?: string, + amount?: number, + created_at?: TDateISO, + order_items_attributes: Array<{ + id: number, + orderable_type: string, + orderable_id: number, + orderable_name: string, + quantity: number, + amount: number, + is_offered: boolean + }>, +} diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index 987283682..75b5d5c1f 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -622,6 +622,17 @@ angular.module('application.router', ['ui.router']) } }) + // cart + .state('app.public.cart', { + url: '/cart', + views: { + 'main@': { + templateUrl: '/cart/index.html', + controller: 'CartController' + } + } + }) + // --- namespace /admin/... --- // calendar .state('app.admin.calendar', { diff --git a/app/frontend/templates/cart/index.html b/app/frontend/templates/cart/index.html new file mode 100644 index 000000000..7f783a09c --- /dev/null +++ b/app/frontend/templates/cart/index.html @@ -0,0 +1,19 @@ +
+
+
+
+ +
+
+
+
+

{{ 'app.public.cart.my_cart' }}

+
+
+
+
+ + +
+ +
diff --git a/app/models/order.rb b/app/models/order.rb new file mode 100644 index 000000000..2d848f325 --- /dev/null +++ b/app/models/order.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Order is a model for the user hold information of order +class Order < ApplicationRecord + belongs_to :statistic_profile + has_many :order_items, dependent: :destroy + + ALL_STATES = %w[cart].freeze + enum state: ALL_STATES.zip(ALL_STATES).to_h + + validates :token, :state, presence: true +end diff --git a/app/models/order_item.rb b/app/models/order_item.rb new file mode 100644 index 000000000..f6e76ec5c --- /dev/null +++ b/app/models/order_item.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# A single line inside an Order. Can be an article of Order +class OrderItem < ApplicationRecord + belongs_to :order + belongs_to :orderable, polymorphic: true + + validates :orderable, :order_id, :amount, presence: true +end diff --git a/app/policies/cart_policy.rb b/app/policies/cart_policy.rb new file mode 100644 index 000000000..34ebb43ae --- /dev/null +++ b/app/policies/cart_policy.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Check the access policies for API::CartController +class CartPolicy < ApplicationPolicy + def create? + true + end + + %w[add_item remove_item set_quantity].each do |action| + define_method "#{action}?" do + user.privileged? || (record.statistic_profile.user_id == user.id) + end + end +end diff --git a/app/services/cart/add_item_service.rb b/app/services/cart/add_item_service.rb new file mode 100644 index 000000000..4f09104c0 --- /dev/null +++ b/app/services/cart/add_item_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Provides methods for add order item to cart +class Cart::AddItemService + def call(order, orderable, quantity = 1) + return order if quantity.to_i.zero? + + raise Cart::InactiveProductError unless orderable.is_active + + raise Cart::OutStockError if quantity > orderable.stock['external'] + + item = order.order_items.find_by(orderable: orderable) + if item.nil? + item = order.order_items.new(quantity: quantity, orderable: orderable, amount: orderable.amount) + else + item.quantity += quantity.to_i + end + order.amount += (orderable.amount * quantity.to_i) + ActiveRecord::Base.transaction do + item.save + order.save + end + order.reload + end +end diff --git a/app/services/cart/create_service.rb b/app/services/cart/create_service.rb new file mode 100644 index 000000000..885bc00ec --- /dev/null +++ b/app/services/cart/create_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Provides methods for create cart +class Cart::CreateService + def call(user) + token = GenerateTokenService.new.call(Order) + order_param = { + token: token, + state: 'cart', + amount: 0 + } + if user + order_param[:statistic_profile_id] = user.statistic_profile.id if user.member? + + order_param[:operator_id] = user.id if user.privileged? + end + Order.create!(order_param) + end +end diff --git a/app/services/cart/remove_item_service.rb b/app/services/cart/remove_item_service.rb new file mode 100644 index 000000000..8d7806aa8 --- /dev/null +++ b/app/services/cart/remove_item_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Provides methods for remove order item to cart +class Cart::RemoveItemService + def call(order, orderable) + item = order.order_items.find_by(orderable: orderable) + + raise ActiveRecord::RecordNotFound if item.nil? + + order.amount -= (item.amount * item.quantity.to_i) + ActiveRecord::Base.transaction do + item.destroy! + order.save + end + order.reload + end +end diff --git a/app/services/cart/set_quantity_service.rb b/app/services/cart/set_quantity_service.rb new file mode 100644 index 000000000..d1ad0332c --- /dev/null +++ b/app/services/cart/set_quantity_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Provides methods for update quantity of order item +class Cart::SetQuantityService + def call(order, orderable, quantity = nil) + return order if quantity.to_i.zero? + + raise Cart::OutStockError if quantity > orderable.stock['external'] + + item = order.order_items.find_by(orderable: orderable) + + raise ActiveRecord::RecordNotFound if item.nil? + + different_quantity = item.quantity - quantiy.to_i + order.amount += (orderable.amount * different_quantity) + ActiveRecord::Base.transaction do + item.update(quantity: quantity) + order.save + end + order.reload + end +end diff --git a/app/services/generate_token_service.rb b/app/services/generate_token_service.rb new file mode 100644 index 000000000..d0ed74972 --- /dev/null +++ b/app/services/generate_token_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Generate a unique token +class GenerateTokenService + def call(model_class = Order) + loop do + token = "#{random_token}#{unique_ending}" + break token unless model_class.exists?(token: token) + end + end + + private + + def random_token + SecureRandom.urlsafe_base64(nil, false) + end + + def unique_ending + (Time.now.to_f * 1000).to_i + end +end diff --git a/app/views/api/orders/_order.json.jbuilder b/app/views/api/orders/_order.json.jbuilder new file mode 100644 index 000000000..c5883e467 --- /dev/null +++ b/app/views/api/orders/_order.json.jbuilder @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +json.extract! order, :id, :token, :statistic_profile_id, :operator_id, :reference, :state, :created_at +json.amount order.amount / 100.0 if order.amount.present? + +json.order_items_attributes order.order_items do |item| + json.id item.id + json.orderable_type item.orderable_type + json.orderable_id item.orderable_id + json.orderable_name item.orderable.name + json.quantity item.quantity + json.amount item.amount / 100.0 + json.is_offered item.is_offered +end diff --git a/app/views/api/orders/show.json.jbuilder b/app/views/api/orders/show.json.jbuilder new file mode 100644 index 000000000..d3b6da6f5 --- /dev/null +++ b/app/views/api/orders/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/orders/order', order: @order diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index 0a5c733cd..1672b517c 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -384,6 +384,8 @@ en: add: "Add" store_product: unexpected_error_occurred: "An unexpected error occurred. Please try again later." + cart: + my_cart: "My Cart" tour: conclusion: title: "Thank you for your attention" diff --git a/config/locales/app.public.fr.yml b/config/locales/app.public.fr.yml index 7265b859d..5325f6127 100644 --- a/config/locales/app.public.fr.yml +++ b/config/locales/app.public.fr.yml @@ -384,6 +384,8 @@ fr: add: "Ajouter" store_product: unexpected_error_occurred: "An unexpected error occurred. Please try again later." + cart: + my_cart: "Mon Panier" tour: conclusion: title: "Merci de votre attention" diff --git a/config/routes.rb b/config/routes.rb index e9dc37f22..ef60a1515 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -155,6 +155,11 @@ Rails.application.routes.draw do end resources :products + resources :cart, only: %i[create] do + put 'add_item', on: :collection + put 'remove_item', on: :collection + put 'set_quantity', on: :collection + end # for admin resources :trainings do @@ -268,7 +273,7 @@ Rails.application.routes.draw do post '/stats/global/export', to: 'api/statistics#export_global' post '_search/scroll', to: 'api/statistics#scroll' - match '/project_collaborator/:valid_token', to: 'api/projects#collaborator_valid', via: :get + get '/project_collaborator/:valid_token', to: 'api/projects#collaborator_valid' authenticate :user, ->(u) { u.admin? } do mount Sidekiq::Web => '/admin/sidekiq' diff --git a/db/migrate/20220808161314_create_orders.rb b/db/migrate/20220808161314_create_orders.rb new file mode 100644 index 000000000..180236fee --- /dev/null +++ b/db/migrate/20220808161314_create_orders.rb @@ -0,0 +1,14 @@ +class CreateOrders < ActiveRecord::Migration[5.2] + def change + create_table :orders do |t| + t.belongs_to :statistic_profile, foreign_key: true + t.integer :operator_id + t.string :token + t.string :reference + t.string :state + t.integer :amount + + t.timestamps + end + end +end diff --git a/db/migrate/20220818160821_create_order_items.rb b/db/migrate/20220818160821_create_order_items.rb new file mode 100644 index 000000000..456d52173 --- /dev/null +++ b/db/migrate/20220818160821_create_order_items.rb @@ -0,0 +1,16 @@ +# frozen_string_literal:true + +# OrderItem for save article of Order +class CreateOrderItems < ActiveRecord::Migration[5.2] + def change + create_table :order_items do |t| + t.belongs_to :order, foreign_key: true + t.references :orderable, polymorphic: true + t.integer :amount + t.integer :quantity + t.boolean :is_offered + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index ca31162a3..1025fe2e8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_08_05_083431) do +ActiveRecord::Schema.define(version: 2022_08_18_160821) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -445,6 +445,31 @@ ActiveRecord::Schema.define(version: 2022_08_05_083431) do t.datetime "updated_at", null: false end + create_table "order_items", force: :cascade do |t| + t.bigint "order_id" + t.string "orderable_type" + t.bigint "orderable_id" + t.integer "amount" + t.integer "quantity" + t.boolean "is_offered" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["order_id"], name: "index_order_items_on_order_id" + t.index ["orderable_type", "orderable_id"], name: "index_order_items_on_orderable_type_and_orderable_id" + end + + create_table "orders", force: :cascade do |t| + t.bigint "statistic_profile_id" + t.integer "operator_id" + t.string "token" + t.string "reference" + t.string "state" + t.integer "amount" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["statistic_profile_id"], name: "index_orders_on_statistic_profile_id" + end + create_table "organizations", id: :serial, force: :cascade do |t| t.string "name" t.datetime "created_at", null: false @@ -1133,6 +1158,8 @@ ActiveRecord::Schema.define(version: 2022_08_05_083431) do add_foreign_key "invoices", "statistic_profiles" add_foreign_key "invoices", "wallet_transactions" add_foreign_key "invoicing_profiles", "users" + add_foreign_key "order_items", "orders" + add_foreign_key "orders", "statistic_profiles" add_foreign_key "organizations", "invoicing_profiles" add_foreign_key "payment_gateway_objects", "payment_gateway_objects" add_foreign_key "payment_schedule_items", "invoices" diff --git a/package.json b/package.json index 852452ee8..7144699d0 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "jasny-bootstrap": "3.1", "jquery": ">=3.5.0", "jquery-ujs": "^1.2.2", + "js-cookie": "^3.0.1", "medium-editor": "^5.23.3", "mini-css-extract-plugin": "^2.6.0", "moment": "2.29", diff --git a/yarn.lock b/yarn.lock index b82199d18..0add086b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5272,6 +5272,11 @@ jquery@>=3.5.0: resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470" integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw== +js-cookie@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.1.tgz#9e39b4c6c2f56563708d7d31f6f5f21873a92414" + integrity sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" From cfd21adb60b57b5a028df4addd697b28c8e6e036 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Sat, 20 Aug 2022 18:47:15 +0200 Subject: [PATCH 041/361] cart button --- .../components/cart/cart-button.tsx | 48 +++++++++++++++++++ .../javascript/components/cart/store-cart.tsx | 33 +++++++++++-- .../components/store/store-product-item.tsx | 16 +++++-- .../src/javascript/components/store/store.tsx | 9 +++- app/frontend/templates/store/index.html | 1 + app/models/product_category.rb | 3 ++ app/services/cart/set_quantity_service.rb | 6 +-- config/locales/app.public.en.yml | 5 ++ config/locales/app.public.fr.yml | 7 ++- package.json | 1 + yarn.lock | 5 ++ 11 files changed, 119 insertions(+), 15 deletions(-) create mode 100644 app/frontend/src/javascript/components/cart/cart-button.tsx diff --git a/app/frontend/src/javascript/components/cart/cart-button.tsx b/app/frontend/src/javascript/components/cart/cart-button.tsx new file mode 100644 index 000000000..3586ec263 --- /dev/null +++ b/app/frontend/src/javascript/components/cart/cart-button.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { Order } from '../../models/order'; +import { useCustomEventListener } from 'react-custom-events'; + +declare const Application: IApplication; + +/** + * This component shows my cart button + */ +const CartButton: React.FC = () => { + const { t } = useTranslation('public'); + const [cart, setCart] = useState(); + useCustomEventListener('CartUpdate', (data) => { + setCart(data); + }); + + /** + * Goto cart page + */ + const showCart = () => { + window.location.href = '/#!/cart'; + }; + + if (cart) { + return ( +
+ + {cart.order_items_attributes.length} +
{t('app.public.cart_button.my_cart')}
+
+ ); + } + return null; +}; + +const CartButtonWrapper: React.FC = () => { + return ( + + + + ); +}; + +Application.Components.component('cartButton', react2angular(CartButtonWrapper)); diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index 5ca920d38..1ac2c7a2b 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -20,7 +20,7 @@ interface StoreCartProps { const StoreCart: React.FC = ({ onError }) => { const { t } = useTranslation('public'); - const { loading, cart, setCart } = useCart(); + const { cart, setCart } = useCart(); /** * Remove the product from cart @@ -35,21 +35,46 @@ const StoreCart: React.FC = ({ onError }) => { }; }; + /** + * Change product quantity + */ + const changeProductQuantity = (item) => { + return (e: React.BaseSyntheticEvent) => { + CartAPI.setQuantity(cart, item.orderable_id, e.target.value).then(data => { + setCart(data); + }); + }; + }; + + /** + * Checkout cart + */ + const checkout = () => { + console.log('checkout .....'); + }; + return (
- {loading &&

loading

} {cart && cart.order_items_attributes.map(item => (
{item.orderable_name}
{FormatLib.price(item.amount)}
{item.quantity}
+
{FormatLib.price(item.quantity * item.amount)}
- {t('app.public.store_cart.remove_item')} +
))} - {cart &&

{cart.amount}

} + {cart &&

Totale: {FormatLib.price(cart.amount)}

} + + {t('app.public.store_cart.checkout')} +
); }; diff --git a/app/frontend/src/javascript/components/store/store-product-item.tsx b/app/frontend/src/javascript/components/store/store-product-item.tsx index fc7aa00f8..1c8a0e8fa 100644 --- a/app/frontend/src/javascript/components/store/store-product-item.tsx +++ b/app/frontend/src/javascript/components/store/store-product-item.tsx @@ -10,12 +10,13 @@ import CartAPI from '../../api/cart'; interface StoreProductItemProps { product: Product, cart: Order, + onSuccessAddProductToCart: (cart: Order) => void } /** * This component shows a product item in store */ -export const StoreProductItem: React.FC = ({ product, cart }) => { +export const StoreProductItem: React.FC = ({ product, cart, onSuccessAddProductToCart }) => { const { t } = useTranslation('public'); /** @@ -33,6 +34,9 @@ export const StoreProductItem: React.FC = ({ product, car * Return product's stock status */ const productStockStatus = (product: Product) => { + if (product.stock.external === 0) { + return {t('app.public.store_product_item.out_of_stock')}; + } if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) { return {t('app.public.store_product_item.limited_stock')}; } @@ -45,7 +49,7 @@ export const StoreProductItem: React.FC = ({ product, car const addProductToCart = (e: React.BaseSyntheticEvent) => { e.preventDefault(); e.stopPropagation(); - CartAPI.addItem(cart, product.id, 1); + CartAPI.addItem(cart, product.id, 1).then(onSuccessAddProductToCart); }; /** @@ -66,9 +70,11 @@ export const StoreProductItem: React.FC = ({ product, car
{FormatLib.price(product.amount)}
{productStockStatus(product)} - - {t('app.public.store_product_item.add')} - + {product.stock.external > 0 && + + {t('app.public.store_product_item.add')} + + }
); diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx index 78339cf0a..e93789487 100644 --- a/app/frontend/src/javascript/components/store/store.tsx +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -8,6 +8,7 @@ import { Product } from '../../models/product'; import ProductAPI from '../../api/product'; import { StoreProductItem } from './store-product-item'; import useCart from '../../hooks/use-cart'; +import { emitCustomEvent } from 'react-custom-events'; declare const Application: IApplication; @@ -21,7 +22,7 @@ interface StoreProps { const Store: React.FC = ({ onError }) => { const { t } = useTranslation('public'); - const { cart } = useCart(); + const { cart, setCart } = useCart(); const [products, setProducts] = useState>([]); @@ -33,6 +34,10 @@ const Store: React.FC = ({ onError }) => { }); }, []); + useEffect(() => { + emitCustomEvent('CartUpdate', cart); + }, [cart]); + return (
@@ -71,7 +76,7 @@ const Store: React.FC = ({ onError }) => {
{products.map((product) => ( - + ))}
diff --git a/app/frontend/templates/store/index.html b/app/frontend/templates/store/index.html index ef8bf3ff7..20d00a606 100644 --- a/app/frontend/templates/store/index.html +++ b/app/frontend/templates/store/index.html @@ -13,6 +13,7 @@
+
diff --git a/app/models/product_category.rb b/app/models/product_category.rb index fdc492f83..40af30a1a 100644 --- a/app/models/product_category.rb +++ b/app/models/product_category.rb @@ -3,6 +3,9 @@ # Category is a first-level filter, used to categorize Products. # It is mandatory to choose a Category when creating a Product. class ProductCategory < ApplicationRecord + extend FriendlyId + friendly_id :name, use: :slugged + validates :name, :slug, presence: true belongs_to :parent, class_name: 'ProductCategory' diff --git a/app/services/cart/set_quantity_service.rb b/app/services/cart/set_quantity_service.rb index d1ad0332c..9d0095c34 100644 --- a/app/services/cart/set_quantity_service.rb +++ b/app/services/cart/set_quantity_service.rb @@ -5,16 +5,16 @@ class Cart::SetQuantityService def call(order, orderable, quantity = nil) return order if quantity.to_i.zero? - raise Cart::OutStockError if quantity > orderable.stock['external'] + raise Cart::OutStockError if quantity.to_i > orderable.stock['external'] item = order.order_items.find_by(orderable: orderable) raise ActiveRecord::RecordNotFound if item.nil? - different_quantity = item.quantity - quantiy.to_i + different_quantity = quantity.to_i - item.quantity order.amount += (orderable.amount * different_quantity) ActiveRecord::Base.transaction do - item.update(quantity: quantity) + item.update(quantity: quantity.to_i) order.save end order.reload diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index 1672b517c..ac9bf7ab1 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -381,11 +381,16 @@ en: store_product_item: available: "Available" limited_stock: "Limited stock" + out_of_stock: "Out of stock" add: "Add" store_product: unexpected_error_occurred: "An unexpected error occurred. Please try again later." cart: my_cart: "My Cart" + cart_button: + my_cart: "My Cart" + store_cart: + checkout: "Checkout" tour: conclusion: title: "Thank you for your attention" diff --git a/config/locales/app.public.fr.yml b/config/locales/app.public.fr.yml index 5325f6127..e971c8e5f 100644 --- a/config/locales/app.public.fr.yml +++ b/config/locales/app.public.fr.yml @@ -381,11 +381,16 @@ fr: store_product_item: available: "Disponible" limited_stock: "Stock limité" + out_of_stock: "Épuisé" add: "Ajouter" store_product: - unexpected_error_occurred: "An unexpected error occurred. Please try again later." + unexpected_error_occurred: "Une erreur inattendue s'est produite. Veuillez réessayer ultérieurement." cart: my_cart: "Mon Panier" + cart_button: + my_cart: "Mon Panier" + store_cart: + checkout: "Valider mon panier" tour: conclusion: title: "Merci de votre attention" diff --git a/package.json b/package.json index 7144699d0..00e5295bf 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "rails-erb-loader": "^5.5.2", "react": "^17.0.2", "react-cool-onclickoutside": "^1.7.0", + "react-custom-events": "^1.1.1", "react-dom": "^17.0.2", "react-hook-form": "^7.30.0", "react-i18next": "^11.15.6", diff --git a/yarn.lock b/yarn.lock index 0add086b9..25384a7b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6588,6 +6588,11 @@ react-cool-onclickoutside@^1.7.0: resolved "https://registry.yarnpkg.com/react-cool-onclickoutside/-/react-cool-onclickoutside-1.7.0.tgz#abc844e14852220fe15f81d7ef44976d15cd9980" integrity sha512-HVZK2155Unee+enpoHKyYP2UdQK69thw90XAOUCjvJBcgRSgfRPgWWt/W1dYzoGp3+nleAa8SJxF1d4FMA4Qmw== +react-custom-events@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/react-custom-events/-/react-custom-events-1.1.1.tgz#792f126e897043a14b9f27a4c5ab7072ff235ceb" + integrity sha512-71iEu3zHsBn3uvF+Sq4Fu5imtRt+cLZO6nG2zqUhdqGVIpZIfeLcl6yieqPghrE+18KFrS5BaHD0NBPP/EZJNw== + react-dom@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" From 396248ed2b5c253ac1ccba525a6e16c3d0c2cebb Mon Sep 17 00:00:00 2001 From: Du Peng Date: Sat, 20 Aug 2022 20:49:51 +0200 Subject: [PATCH 042/361] manage cart session when user login or logout --- app/controllers/api/cart_controller.rb | 12 +++++++---- .../javascript/components/cart/store-cart.tsx | 20 +++++++++++++------ .../src/javascript/components/store/store.tsx | 18 ++++++++++++----- .../src/javascript/controllers/application.js | 5 +++-- app/frontend/src/javascript/hooks/use-cart.ts | 10 +++++++++- app/frontend/templates/cart/index.html | 2 +- app/frontend/templates/store/index.html | 2 +- app/policies/cart_policy.rb | 4 +++- 8 files changed, 52 insertions(+), 21 deletions(-) diff --git a/app/controllers/api/cart_controller.rb b/app/controllers/api/cart_controller.rb index d97c21a78..15dd592b8 100644 --- a/app/controllers/api/cart_controller.rb +++ b/app/controllers/api/cart_controller.rb @@ -2,12 +2,16 @@ # API Controller for manage user's cart class API::CartController < API::ApiController - before_action :current_order + before_action :current_order, except: %i[create] before_action :ensure_order, except: %i[create] def create authorize :cart, :create? - @order = current_order if current_order.present? + @order = Order.find_by(token: order_token) + @order = Order.find_by(statistic_profile_id: current_user.statistic_profile.id, state: 'cart') if @order.nil? && current_user&.member? + if @order && @order.statistic_profile_id.nil? && current_user&.member? + @order.update(statistic_profile_id: current_user.statistic_profile.id) + end @order ||= Cart::CreateService.new.call(current_user) render 'api/orders/show' end @@ -19,13 +23,13 @@ class API::CartController < API::ApiController end def remove_item - authorize :cart, policy_class: CartPolicy + authorize @current_order, policy_class: CartPolicy @order = Cart::RemoveItemService.new.call(@current_order, orderable) render 'api/orders/show' end def set_quantity - authorize :cart, policy_class: CartPolicy + authorize @current_order, policy_class: CartPolicy @order = Cart::SetQuantityService.new.call(@current_order, orderable, cart_params[:quantity]) render 'api/orders/show' end diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index 1ac2c7a2b..d24adffc2 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; import { Loader } from '../base/loader'; @@ -7,20 +7,28 @@ import { FabButton } from '../base/fab-button'; import useCart from '../../hooks/use-cart'; import FormatLib from '../../lib/format'; import CartAPI from '../../api/cart'; +import { User } from '../../models/user'; declare const Application: IApplication; interface StoreCartProps { onError: (message: string) => void, + currentUser: User, } /** * This component shows user's cart */ -const StoreCart: React.FC = ({ onError }) => { +const StoreCart: React.FC = ({ onError, currentUser }) => { const { t } = useTranslation('public'); - const { cart, setCart } = useCart(); + const { cart, setCart, reloadCart } = useCart(); + + useEffect(() => { + if (currentUser) { + reloadCart(); + } + }, [currentUser]); /** * Remove the product from cart @@ -79,12 +87,12 @@ const StoreCart: React.FC = ({ onError }) => { ); }; -const StoreCartWrapper: React.FC = ({ onError }) => { +const StoreCartWrapper: React.FC = (props) => { return ( - + ); }; -Application.Components.component('storeCart', react2angular(StoreCartWrapper, ['onError'])); +Application.Components.component('storeCart', react2angular(StoreCartWrapper, ['onError', 'currentUser'])); diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx index e93789487..5cf4a7e30 100644 --- a/app/frontend/src/javascript/components/store/store.tsx +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -9,20 +9,22 @@ import ProductAPI from '../../api/product'; import { StoreProductItem } from './store-product-item'; import useCart from '../../hooks/use-cart'; import { emitCustomEvent } from 'react-custom-events'; +import { User } from '../../models/user'; declare const Application: IApplication; interface StoreProps { onError: (message: string) => void, + currentUser: User, } /** * This component shows public store */ -const Store: React.FC = ({ onError }) => { +const Store: React.FC = ({ onError, currentUser }) => { const { t } = useTranslation('public'); - const { cart, setCart } = useCart(); + const { cart, setCart, reloadCart } = useCart(); const [products, setProducts] = useState>([]); @@ -38,6 +40,12 @@ const Store: React.FC = ({ onError }) => { emitCustomEvent('CartUpdate', cart); }, [cart]); + useEffect(() => { + if (currentUser) { + reloadCart(); + } + }, [currentUser]); + return (
@@ -85,12 +93,12 @@ const Store: React.FC = ({ onError }) => { ); }; -const StoreWrapper: React.FC = ({ onError }) => { +const StoreWrapper: React.FC = (props) => { return ( - + ); }; -Application.Components.component('store', react2angular(StoreWrapper, ['onError'])); +Application.Components.component('store', react2angular(StoreWrapper, ['onError', 'currentUser'])); diff --git a/app/frontend/src/javascript/controllers/application.js b/app/frontend/src/javascript/controllers/application.js index 93d9e6c1e..d2ae5b337 100644 --- a/app/frontend/src/javascript/controllers/application.js +++ b/app/frontend/src/javascript/controllers/application.js @@ -1,6 +1,6 @@ -Application.Controllers.controller('ApplicationController', ['$rootScope', '$scope', '$transitions', '$window', '$locale', '$timeout', 'Session', 'AuthService', 'Auth', '$uibModal', '$state', 'growl', 'Notification', '$interval', 'Setting', '_t', 'Version', 'Help', - function ($rootScope, $scope, $transitions, $window, $locale, $timeout, Session, AuthService, Auth, $uibModal, $state, growl, Notification, $interval, Setting, _t, Version, Help) { +Application.Controllers.controller('ApplicationController', ['$rootScope', '$scope', '$transitions', '$window', '$locale', '$timeout', 'Session', 'AuthService', 'Auth', '$uibModal', '$state', 'growl', 'Notification', '$interval', 'Setting', '_t', 'Version', 'Help', '$cookies', + function ($rootScope, $scope, $transitions, $window, $locale, $timeout, Session, AuthService, Auth, $uibModal, $state, growl, Notification, $interval, Setting, _t, Version, Help, $cookies) { /* PRIVATE STATIC CONSTANTS */ // User's notifications will get refreshed every 30s @@ -58,6 +58,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco total: 0, unread: 0 }; + $cookies.remove('fablab_cart_token'); return $state.go('app.public.home'); }, function (error) { console.error(`An error occurred logging out: ${error}`); diff --git a/app/frontend/src/javascript/hooks/use-cart.ts b/app/frontend/src/javascript/hooks/use-cart.ts index 587647ff2..bd7d8382f 100644 --- a/app/frontend/src/javascript/hooks/use-cart.ts +++ b/app/frontend/src/javascript/hooks/use-cart.ts @@ -25,5 +25,13 @@ export default function useCart () { } }, []); - return { loading, cart, error, setCart }; + const reloadCart = async () => { + setLoading(true); + const currentCartToken = getCartToken(); + const data = await CartAPI.create(currentCartToken); + setCart(data); + setLoading(false); + }; + + return { loading, cart, error, setCart, reloadCart }; } diff --git a/app/frontend/templates/cart/index.html b/app/frontend/templates/cart/index.html index 7f783a09c..75c73bbed 100644 --- a/app/frontend/templates/cart/index.html +++ b/app/frontend/templates/cart/index.html @@ -15,5 +15,5 @@
- +
diff --git a/app/frontend/templates/store/index.html b/app/frontend/templates/store/index.html index 20d00a606..fd39e9d62 100644 --- a/app/frontend/templates/store/index.html +++ b/app/frontend/templates/store/index.html @@ -21,5 +21,5 @@
- +
diff --git a/app/policies/cart_policy.rb b/app/policies/cart_policy.rb index 34ebb43ae..0aad87849 100644 --- a/app/policies/cart_policy.rb +++ b/app/policies/cart_policy.rb @@ -8,7 +8,9 @@ class CartPolicy < ApplicationPolicy %w[add_item remove_item set_quantity].each do |action| define_method "#{action}?" do - user.privileged? || (record.statistic_profile.user_id == user.id) + return user.privileged? || (record.statistic_profile_id == user.statistic_profile.id) if user + + record.statistic_profile_id.nil? end end end From 73a2e328a547b8aa5353fc7ef5e644d0228b493c Mon Sep 17 00:00:00 2001 From: Du Peng Date: Sun, 21 Aug 2022 15:34:23 +0200 Subject: [PATCH 043/361] set operator if admin create cart --- app/controllers/api/cart_controller.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/cart_controller.rb b/app/controllers/api/cart_controller.rb index 15dd592b8..a41cb6b4c 100644 --- a/app/controllers/api/cart_controller.rb +++ b/app/controllers/api/cart_controller.rb @@ -9,8 +9,9 @@ class API::CartController < API::ApiController authorize :cart, :create? @order = Order.find_by(token: order_token) @order = Order.find_by(statistic_profile_id: current_user.statistic_profile.id, state: 'cart') if @order.nil? && current_user&.member? - if @order && @order.statistic_profile_id.nil? && current_user&.member? - @order.update(statistic_profile_id: current_user.statistic_profile.id) + if @order + @order.update(statistic_profile_id: current_user.statistic_profile.id) if @order.statistic_profile_id.nil? && current_user&.member? + @order.update(operator_id: current_user.id) if @order.operator_id.nil? && current_user&.privileged? end @order ||= Cart::CreateService.new.call(current_user) render 'api/orders/show' From 5ec541d854fd8d6f5d0cb689418b53488ad4a0c8 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Sun, 21 Aug 2022 19:08:10 +0200 Subject: [PATCH 044/361] add checkout controller and refactoring cart controller --- app/controllers/api/cart_controller.rb | 14 ++------------ app/controllers/api/checkout_controller.rb | 6 ++++++ app/controllers/concerns/api/order_concern.rb | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 app/controllers/api/checkout_controller.rb create mode 100644 app/controllers/concerns/api/order_concern.rb diff --git a/app/controllers/api/cart_controller.rb b/app/controllers/api/cart_controller.rb index a41cb6b4c..f26d97469 100644 --- a/app/controllers/api/cart_controller.rb +++ b/app/controllers/api/cart_controller.rb @@ -2,6 +2,8 @@ # API Controller for manage user's cart class API::CartController < API::ApiController + include API::OrderConcern + before_action :current_order, except: %i[create] before_action :ensure_order, except: %i[create] @@ -37,18 +39,6 @@ class API::CartController < API::ApiController private - def order_token - request.headers['X-Fablab-Order-Token'] || cart_params[:order_token] - end - - def current_order - @current_order = Order.find_by(token: order_token) - end - - def ensure_order - raise ActiveRecord::RecordNotFound if @current_order.nil? - end - def orderable Product.find(cart_params[:orderable_id]) end diff --git a/app/controllers/api/checkout_controller.rb b/app/controllers/api/checkout_controller.rb new file mode 100644 index 000000000..4c9425747 --- /dev/null +++ b/app/controllers/api/checkout_controller.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# API Controller for cart checkout +class API::CheckoutController < API::ApiController + include ::API::OrderConcern +end diff --git a/app/controllers/concerns/api/order_concern.rb b/app/controllers/concerns/api/order_concern.rb new file mode 100644 index 000000000..eb38d42d0 --- /dev/null +++ b/app/controllers/concerns/api/order_concern.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Concern for CartController and CheckoutController +module API::OrderConcern + private + + def order_token + request.headers['X-Fablab-Order-Token'] || cart_params[:order_token] + end + + def current_order + @current_order = Order.find_by(token: order_token) + end + + def ensure_order + raise ActiveRecord::RecordNotFound if @current_order.nil? + end +end From 4396bb0ca07b7bcc4f52c56b8c546fbb57d9ff98 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 11 Jul 2022 19:17:36 +0200 Subject: [PATCH 045/361] store product category create/list/update/delete --- Gemfile | 2 + Gemfile.lock | 3 + .../api/product_categories_controller.rb | 50 ++++++++ .../src/javascript/api/product-category.ts | 30 +++++ .../javascript/components/base/fab-input.tsx | 8 +- .../store/product-categories-list.tsx | 50 ++++++++ .../components/store/product-categories.tsx | 114 ++++++++++++++++++ .../store/product-category-form.tsx | 99 +++++++++++++++ .../store/product-category-modal.tsx | 100 +++++++++++++++ .../src/javascript/controllers/admin/store.js | 38 ++++++ .../src/javascript/controllers/main_nav.js | 6 + .../src/javascript/models/product-category.ts | 7 ++ app/frontend/src/javascript/router.js | 10 ++ .../templates/admin/store/categories.html | 1 + app/frontend/templates/admin/store/index.html | 42 +++++++ .../templates/admin/store/orders.html | 1 + .../templates/admin/store/products.html | 1 + .../templates/admin/store/settings.html | 1 + app/models/product_category.rb | 12 ++ app/policies/product_category_policy.rb | 16 +++ app/services/product_category_service.rb | 13 ++ .../_product_category.json.jbuilder | 3 + .../product_categories/create.json.jbuilder | 3 + .../product_categories/index.json.jbuilder | 5 + .../api/product_categories/show.json.jbuilder | 3 + .../product_categories/update.json.jbuilder | 3 + config/locales/app.admin.en.yml | 24 ++++ config/locales/app.admin.fr.yml | 24 ++++ config/locales/app.public.en.yml | 1 + config/locales/app.public.fr.yml | 1 + config/routes.rb | 2 + ...0220620072750_create_product_categories.rb | 12 ++ db/schema.rb | 30 +++-- package.json | 3 + yarn.lock | 28 +++++ 35 files changed, 731 insertions(+), 15 deletions(-) create mode 100644 app/controllers/api/product_categories_controller.rb create mode 100644 app/frontend/src/javascript/api/product-category.ts create mode 100644 app/frontend/src/javascript/components/store/product-categories-list.tsx create mode 100644 app/frontend/src/javascript/components/store/product-categories.tsx create mode 100644 app/frontend/src/javascript/components/store/product-category-form.tsx create mode 100644 app/frontend/src/javascript/components/store/product-category-modal.tsx create mode 100644 app/frontend/src/javascript/controllers/admin/store.js create mode 100644 app/frontend/src/javascript/models/product-category.ts create mode 100644 app/frontend/templates/admin/store/categories.html create mode 100644 app/frontend/templates/admin/store/index.html create mode 100644 app/frontend/templates/admin/store/orders.html create mode 100644 app/frontend/templates/admin/store/products.html create mode 100644 app/frontend/templates/admin/store/settings.html create mode 100644 app/models/product_category.rb create mode 100644 app/policies/product_category_policy.rb create mode 100644 app/services/product_category_service.rb create mode 100644 app/views/api/product_categories/_product_category.json.jbuilder create mode 100644 app/views/api/product_categories/create.json.jbuilder create mode 100644 app/views/api/product_categories/index.json.jbuilder create mode 100644 app/views/api/product_categories/show.json.jbuilder create mode 100644 app/views/api/product_categories/update.json.jbuilder create mode 100644 db/migrate/20220620072750_create_product_categories.rb diff --git a/Gemfile b/Gemfile index 46d891218..b6ef82f40 100644 --- a/Gemfile +++ b/Gemfile @@ -145,3 +145,5 @@ gem 'tzinfo-data' gem 'sassc', '= 2.1.0' gem 'redis-session-store' + +gem 'acts_as_list' diff --git a/Gemfile.lock b/Gemfile.lock index 1abd78ea6..c07126939 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -48,6 +48,8 @@ GEM i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) + acts_as_list (1.0.4) + activerecord (>= 4.2) addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) aes_key_wrap (1.1.0) @@ -500,6 +502,7 @@ DEPENDENCIES aasm actionpack-page_caching (= 1.2.2) active_record_query_trace + acts_as_list api-pagination apipie-rails awesome_print diff --git a/app/controllers/api/product_categories_controller.rb b/app/controllers/api/product_categories_controller.rb new file mode 100644 index 000000000..87a0949e3 --- /dev/null +++ b/app/controllers/api/product_categories_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# API Controller for resources of type ProductCategory +# ProductCategorys are used to group products +class API::ProductCategoriesController < API::ApiController + before_action :authenticate_user!, except: :index + before_action :set_product_category, only: %i[show update destroy] + + def index + @product_categories = ProductCategoryService.list + end + + def show; end + + def create + authorize ProductCategory + @product_category = ProductCategory.new(product_category_params) + if @product_category.save + render status: :created + else + render json: @product_category.errors.full_messages, status: :unprocessable_entity + end + end + + def update + authorize @product_category + + if @product_category.update(product_category_params) + render status: :ok + else + render json: @product_category.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + authorize @product_category + ProductCategoryService.destroy(@product_category) + head :no_content + end + + private + + def set_product_category + @product_category = ProductCategory.find(params[:id]) + end + + def product_category_params + params.require(:product_category).permit(:name, :parent_id, :slug, :position) + end +end diff --git a/app/frontend/src/javascript/api/product-category.ts b/app/frontend/src/javascript/api/product-category.ts new file mode 100644 index 000000000..964ef4e8f --- /dev/null +++ b/app/frontend/src/javascript/api/product-category.ts @@ -0,0 +1,30 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { ProductCategory } from '../models/product-category'; + +export default class ProductCategoryAPI { + static async index (): Promise> { + const res: AxiosResponse> = await apiClient.get('/api/product_categories'); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/product_categories/${id}`); + return res?.data; + } + + static async create (productCategory: ProductCategory): Promise { + const res: AxiosResponse = await apiClient.post('/api/product_categories', { product_category: productCategory }); + return res?.data; + } + + static async update (productCategory: ProductCategory): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/product_categories/${productCategory.id}`, { product_category: productCategory }); + return res?.data; + } + + static async destroy (productCategoryId: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/product_categories/${productCategoryId}`); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/base/fab-input.tsx b/app/frontend/src/javascript/components/base/fab-input.tsx index b5500ff84..f7e57dc5c 100644 --- a/app/frontend/src/javascript/components/base/fab-input.tsx +++ b/app/frontend/src/javascript/components/base/fab-input.tsx @@ -36,11 +36,9 @@ export const FabInput: React.FC = ({ id, onChange, defaultValue, * If the default value changes, update the value of the input until there's no content in it. */ useEffect(() => { - if (!inputValue) { - setInputValue(defaultValue); - if (typeof onChange === 'function') { - onChange(defaultValue); - } + setInputValue(defaultValue); + if (typeof onChange === 'function') { + onChange(defaultValue); } }, [defaultValue]); diff --git a/app/frontend/src/javascript/components/store/product-categories-list.tsx b/app/frontend/src/javascript/components/store/product-categories-list.tsx new file mode 100644 index 000000000..b818caabb --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-categories-list.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { FabButton } from '../base/fab-button'; +import { ProductCategory } from '../../models/product-category'; + +interface ProductCategoriesListProps { + productCategories: Array, + onEdit: (category: ProductCategory) => void, + onDelete: (categoryId: number) => void, +} + +/** + * This component shows a Tree list of all Product's Categories + */ +export const ProductCategoriesList: React.FC = ({ productCategories, onEdit, onDelete }) => { + /** + * Init the process of editing the given product category + */ + const editProductCategory = (category: ProductCategory): () => void => { + return (): void => { + onEdit(category); + }; + }; + + /** + * Init the process of delete the given product category + */ + const deleteProductCategory = (categoryId: number): () => void => { + return (): void => { + onDelete(categoryId); + }; + }; + + return ( +
+ {productCategories.map((category) => ( +
+ {category.name} +
+ + + + + + +
+
+ ))} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/product-categories.tsx b/app/frontend/src/javascript/components/store/product-categories.tsx new file mode 100644 index 000000000..6ae88f8a5 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-categories.tsx @@ -0,0 +1,114 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { HtmlTranslate } from '../base/html-translate'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { FabAlert } from '../base/fab-alert'; +import { FabButton } from '../base/fab-button'; +import { ProductCategoriesList } from './product-categories-list'; +import { ProductCategoryModal } from './product-category-modal'; +import { ProductCategory } from '../../models/product-category'; +import ProductCategoryAPI from '../../api/product-category'; + +declare const Application: IApplication; + +interface ProductCategoriesProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component shows a Tree list of all Product's Categories + */ +const ProductCategories: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const [isOpenProductCategoryModal, setIsOpenProductCategoryModal] = useState(false); + const [productCategories, setProductCategories] = useState>([]); + const [productCategory, setProductCategory] = useState(null); + + useEffect(() => { + ProductCategoryAPI.index().then(data => { + setProductCategories(data); + }); + }, []); + + /** + * Open create new product category modal + */ + const openProductCategoryModal = () => { + setIsOpenProductCategoryModal(true); + }; + + /** + * toggle create/edit product category modal + */ + const toggleCreateAndEditProductCategoryModal = () => { + setIsOpenProductCategoryModal(!isOpenProductCategoryModal); + }; + + /** + * callback handle save product category success + */ + const onSaveProductCategorySuccess = (message: string) => { + setIsOpenProductCategoryModal(false); + onSuccess(message); + ProductCategoryAPI.index().then(data => { + setProductCategories(data); + }); + }; + + /** + * Open edit the product category modal + */ + const editProductCategory = (category: ProductCategory) => { + setProductCategory(category); + setIsOpenProductCategoryModal(true); + }; + + /** + * Delete a product category + */ + const deleteProductCategory = async (categoryId: number): Promise => { + try { + await ProductCategoryAPI.destroy(categoryId); + const data = await ProductCategoryAPI.index(); + setProductCategories(data); + onSuccess(t('app.admin.store.product_categories.successfully_deleted')); + } catch (e) { + onError(t('app.admin.store.product_categories.unable_to_delete') + e); + } + }; + + return ( +
+

{t('app.admin.store.product_categories.the_categories')}

+ {t('app.admin.store.product_categories.create_a_product_category')} + + + + + +
+ ); +}; + +const ProductCategoriesWrapper: React.FC = ({ onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('productCategories', react2angular(ProductCategoriesWrapper, ['onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/product-category-form.tsx b/app/frontend/src/javascript/components/store/product-category-form.tsx new file mode 100644 index 000000000..9199d87a4 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-category-form.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import Select from 'react-select'; +import slugify from 'slugify'; +import { FabInput } from '../base/fab-input'; +import { ProductCategory } from '../../models/product-category'; + +interface ProductCategoryFormProps { + productCategories: Array, + productCategory?: ProductCategory, + onChange: (field: string, value: string | number) => void, +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +type selectOption = { value: number, label: string }; + +/** + * Form to set create/edit supporting documents type + */ +export const ProductCategoryForm: React.FC = ({ productCategories, productCategory, onChange }) => { + const { t } = useTranslation('admin'); + + // filter all first level product categorie + const parents = productCategories.filter(c => !c.parent_id); + + const [slug, setSlug] = useState(productCategory?.slug || ''); + + /** + * Return the default first level product category, formatted to match the react-select format + */ + const defaultValue = { value: productCategory?.parent_id, label: productCategory?.name }; + + /** + * Convert all parents to the react-select format + */ + const buildOptions = (): Array => { + return parents.map(t => { + return { value: t.id, label: t.name }; + }); + }; + + /** + * Callback triggered when the selection of parent product category has changed. + */ + const handleCategoryParentChange = (option: selectOption): void => { + onChange('parent_id', option.value); + }; + + /** + * Callback triggered when the name has changed. + */ + const handleNameChange = (value: string): void => { + onChange('name', value); + const _slug = slugify(value, { lower: true }); + setSlug(_slug); + onChange('slug', _slug); + }; + + /** + * Callback triggered when the slug has changed. + */ + const handleSlugChange = (value: string): void => { + onChange('slug', value); + }; + + return ( +
+ +
+ } + defaultValue={productCategory?.name || ''} + placeholder={t('app.admin.store.product_category_form.name')} + onChange={handleNameChange} + debounce={200} + required/> +
+
+ } + defaultValue={slug} + placeholder={t('app.admin.store.product_category_form.slug')} + onChange={handleSlugChange} + debounce={200} + required/> +
+
+ -
- -
+
+ { action === 'delete' + ? <> + + {t('app.admin.store.product_category_form.delete.confirm')} + + {t('app.admin.store.product_category_form.save')} + + : <> + + + + {t('app.admin.store.product_category_form.save')} + + } + ); }; diff --git a/app/frontend/src/javascript/components/store/product-category-modal.tsx b/app/frontend/src/javascript/components/store/product-category-modal.tsx deleted file mode 100644 index c2f3185dd..000000000 --- a/app/frontend/src/javascript/components/store/product-category-modal.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { FabModal } from '../base/fab-modal'; -import { ProductCategoryForm } from './product-category-form'; -import { ProductCategory } from '../../models/product-category'; -import ProductCategoryAPI from '../../api/product-category'; - -interface ProductCategoryModalProps { - isOpen: boolean, - toggleModal: () => void, - onSuccess: (message: string) => void, - onError: (message: string) => void, - productCategories: Array, - productCategory?: ProductCategory, -} - -/** - * Check if string is a valid url slug - */ -function checkIfValidURLSlug (str: string): boolean { - // Regular expression to check if string is a valid url slug - const regexExp = /^[a-z0-9]+(?:-[a-z0-9]+)*$/g; - - return regexExp.test(str); -} - -/** - * Modal dialog to create/edit a category of product - */ -export const ProductCategoryModal: React.FC = ({ isOpen, toggleModal, onSuccess, onError, productCategories, productCategory }) => { - const { t } = useTranslation('admin'); - - const [data, setData] = useState({ - id: productCategory?.id, - name: productCategory?.name || '', - slug: productCategory?.slug || '', - parent_id: productCategory?.parent_id, - position: productCategory?.position - }); - - useEffect(() => { - setData({ - id: productCategory?.id, - name: productCategory?.name || '', - slug: productCategory?.slug || '', - parent_id: productCategory?.parent_id, - position: productCategory?.position - }); - }, [productCategory]); - - /** - * Callback triggered when an inner form field has changed: updates the internal state accordingly - */ - const handleChanged = (field: string, value: string | number) => { - setData({ - ...data, - [field]: value - }); - }; - - /** - * Save the current product category to the API - */ - const handleSave = async (): Promise => { - try { - if (productCategory?.id) { - await ProductCategoryAPI.update(data); - onSuccess(t('app.admin.store.product_category_modal.successfully_updated')); - } else { - await ProductCategoryAPI.create(data); - onSuccess(t('app.admin.store.product_category_modal.successfully_created')); - } - } catch (e) { - if (productCategory?.id) { - onError(t('app.admin.store.product_category_modal.unable_to_update') + e); - } else { - onError(t('app.admin.store.product_category_modal.unable_to_create') + e); - } - } - }; - - /** - * Check if the form is valid (not empty, url valid slug) - */ - const isPreventedSaveProductCategory = (): boolean => { - return !data.name || !data.slug || !checkIfValidURLSlug(data.slug); - }; - - return ( - - - - ); -}; diff --git a/app/frontend/src/javascript/controllers/main_nav.js b/app/frontend/src/javascript/controllers/main_nav.js index cd21250d7..c2a672e02 100644 --- a/app/frontend/src/javascript/controllers/main_nav.js +++ b/app/frontend/src/javascript/controllers/main_nav.js @@ -86,7 +86,7 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm { state: 'app.admin.store', linkText: 'app.public.common.manage_the_store', - linkIcon: 'cogs', + linkIcon: 'cart-plus', authorizedRoles: ['admin', 'manager'] }, $scope.$root.modules.trainings && { diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 14723c579..48fe7e9a3 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -85,6 +85,8 @@ @import "modules/settings/check-list-setting"; @import "modules/settings/user-validation-setting"; @import "modules/socials/fab-socials"; +@import "modules/store/manage-product-category"; +@import "modules/store/product-categories"; @import "modules/subscriptions/free-extend-modal"; @import "modules/subscriptions/renew-modal"; @import "modules/supporting-documents/supporting-documents-files"; diff --git a/app/frontend/src/stylesheets/modules/base/fab-button.scss b/app/frontend/src/stylesheets/modules/base/fab-button.scss index 5f70a2d3a..af0df298b 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-button.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-button.scss @@ -46,4 +46,7 @@ &--icon { margin-right: 0.5em; } + &--icon-only { + display: flex; + } } diff --git a/app/frontend/src/stylesheets/modules/base/fab-modal.scss b/app/frontend/src/stylesheets/modules/base/fab-modal.scss index 4e5d538b3..890c98c3b 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-modal.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-modal.scss @@ -81,6 +81,12 @@ position: relative; padding: 15px; + .subtitle { + margin-bottom: 3.2rem; + @include title-base; + color: var(--gray-hard-darkest); + } + form { display: flex; flex-direction: column; diff --git a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss index 59ac7a110..d8c897b48 100644 --- a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss +++ b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss @@ -64,6 +64,7 @@ border: 1px solid var(--gray-soft-dark); border-radius: var(--border-radius); transition: border-color ease-in-out 0.15s; + font-weight: 400; .icon, .addon { diff --git a/app/frontend/src/stylesheets/modules/store/manage-product-category.scss b/app/frontend/src/stylesheets/modules/store/manage-product-category.scss new file mode 100644 index 000000000..41a61d564 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/manage-product-category.scss @@ -0,0 +1,3 @@ +.manage-product-category { + +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss new file mode 100644 index 000000000..75963f5f2 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -0,0 +1,97 @@ +@mixin btn { + width: 4rem; + height: 4rem; + display: inline-flex; + justify-content: center; + align-items: center; + padding: 0; + background: none; + border: none; + &:active { + color: currentColor; + box-shadow: none; + } +} + +.product-categories { + max-width: 1300px; + margin: 0 auto; + + header { + padding: 2.4rem 0; + display: flex; + justify-content: space-between; + align-items: center; + h2 { + margin: 0; + @include title-lg; + color: var(--gray-hard-darkest); + } + } + + .create-button { + background-color: var(--gray-hard-darkest); + border-color: var(--gray-hard-darkest); + color: var(--gray-soft-lightest); + &:hover { + background-color: var(--gray-hard-light); + border-color: var(--gray-hard-light); + } + } + + &-list { + & > *:not(:last-of-type) { + margin-bottom: 1.6rem; + } + } + &-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + + .itemInfo { + display: flex; + justify-content: flex-end; + align-items: center; + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + &-count { + margin-left: 2.4rem; + @include text-sm; + font-weight: 500; + color: var(--information); + } + } + + .action { + display: flex; + justify-content: flex-end; + align-items: center; + .manage { + overflow: hidden; + display: flex; + border-radius: var(--border-radius); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-button {background: var(--gray-hard-darkest) } + .delete-button {background: var(--error) } + } + } + + .draghandle { + @include btn; + cursor: grab; + } + } +} \ No newline at end of file diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 545fbbe1c..ba8e8aad5 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1894,17 +1894,18 @@ en: title: "Documentation" content: "Click here to access the API online documentation." store: - manage_the_store: "Manage the Store Fablab" + manage_the_store: "Manage the Store" settings: "Settings" all_products: "All products" - categories_of_store: "Categories of store" + categories_of_store: "Store's categories" the_orders: "Orders" product_categories: - create_a_product_category: "Create a category" - the_categories: "Categories" + title: "Categories" info: "Information:
Find below all the categories created. The categories are arranged on two levels maximum, you can arrange them with a drag and drop. The order of the categories will be identical on the public view and the list below. Please note that you can delete a category or a sub-category even if they are associated with products. The latter will be left without categories. If you delete a category that contains sub-categories, the latter will also be deleted. Make sure that your categories are well arranged and save your choice." - successfully_deleted: "The category has been successfully deleted" - unable_to_delete: "Unable to delete the category: " + manage_product_category: + create: "Create a product category" + update: "Modify the product category" + delete: "Delete the product category" product_category_modal: successfully_created: "The new category has been created." unable_to_create: "Unable to create the category: " @@ -1912,8 +1913,12 @@ en: unable_to_update: "Unable to modify the category: " new_product_category: "Create a category" edit_product_category: "Modify a category" - save: "Save" product_category_form: name: "Name of category" slug: "Name of URL" select_parent_product_category: "Choose a parent category (N1)" + delete: + confirm: "Do you really want to delete this product category?" + error: "Unable to delete the category: " + success: "The category has been successfully deleted" + save: "Save" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 93ea64a4e..ff0d0982d 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1900,20 +1900,25 @@ fr: categories_of_store: "Les catégories de la boutique" the_orders: "Les commandes" product_categories: - create_a_product_category: "Créer une catégorie" - the_categories: "Les catégories" - info: "Information:
Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveux maximum, vous pouvez les agancer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. Veillez au bon agencement de vos catégories et sauvegarder votre choix." - successfully_deleted: "La catégorie a bien été supprimé" - unable_to_delete: "Impossible de supprimer the category: " + title: "Les catégories" + info: "Information:
Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveaux maximum, vous pouvez les agencer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. Veillez au bon agencement de vos catégories et sauvegarder votre choix." + manage_product_category: + create: "Create a product category" + update: "Update the product category" + delete: "Delete the product category" product_category_modal: successfully_created: "La catégorie a bien été créée." unable_to_create: "Impossible de créer la catégorie : " successfully_updated: "La nouvelle catégorie a bien été mise à jour." unable_to_update: "Impossible de modifier la catégorie : " new_product_category: "Créer une catégorie" - edit_product_category: "Modifier la catéogirie" - save: "Sauvgarder" + edit_product_category: "Modifier la catégorie" product_category_form: name: "Nom de la catégorie" slug: "Nom de l'URL" select_parent_product_category: "Choisir une catégorie parent (N1)" + delete: + confirm: "Do you really want to delete this product category?" + error: "Impossible de supprimer the catégorie : " + success: "La catégorie a bien été supprimée" + save: "Enregistrer" From bf1700e43a60b638efa3e320cab7b7604993dce2 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 18 Jul 2022 14:57:33 +0200 Subject: [PATCH 050/361] Convert product category form to RHF --- .../store/manage-product-category.tsx | 2 +- .../components/store/product-categories.tsx | 3 +- .../store/product-category-form.tsx | 35 ++++++++++++++----- config/locales/app.admin.en.yml | 12 ++++--- config/locales/app.admin.fr.yml | 12 ++++--- 5 files changed, 46 insertions(+), 18 deletions(-) diff --git a/app/frontend/src/javascript/components/store/manage-product-category.tsx b/app/frontend/src/javascript/components/store/manage-product-category.tsx index 52dcb1557..28b99b63f 100644 --- a/app/frontend/src/javascript/components/store/manage-product-category.tsx +++ b/app/frontend/src/javascript/components/store/manage-product-category.tsx @@ -15,7 +15,7 @@ interface ManageProductCategoryProps { /** * This component shows a button. - * When clicked, we show a modal dialog allowing to fill the parameters of a product category (create new or update existing). + * When clicked, we show a modal dialog allowing to fill the parameters of a product category. */ export const ManageProductCategory: React.FC = ({ productCategories, productCategory, action, onSuccess, onError }) => { const { t } = useTranslation('admin'); diff --git a/app/frontend/src/javascript/components/store/product-categories.tsx b/app/frontend/src/javascript/components/store/product-categories.tsx index 17e72baaf..977bbe01f 100644 --- a/app/frontend/src/javascript/components/store/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/product-categories.tsx @@ -18,7 +18,8 @@ interface ProductCategoriesProps { } /** - * This component shows a Tree list of all Product's Categories + * This component shows a list of all product categories and offer to manager them + * by creating, deleting, modifying and reordering each product categories. */ const ProductCategories: React.FC = ({ onSuccess, onError }) => { const { t } = useTranslation('admin'); diff --git a/app/frontend/src/javascript/components/store/product-category-form.tsx b/app/frontend/src/javascript/components/store/product-category-form.tsx index 339765be7..9f1f31b54 100644 --- a/app/frontend/src/javascript/components/store/product-category-form.tsx +++ b/app/frontend/src/javascript/components/store/product-category-form.tsx @@ -29,7 +29,7 @@ interface ProductCategoryFormProps { export const ProductCategoryForm: React.FC = ({ action, productCategories, productCategory, onSuccess, onError }) => { const { t } = useTranslation('admin'); - const { register, watch, setValue, control, handleSubmit } = useForm({ defaultValues: { ...productCategory } }); + const { register, watch, setValue, control, handleSubmit, formState } = useForm({ defaultValues: { ...productCategory } }); // filter all first level product categorie const parents = productCategories.filter(c => !c.parent_id); @@ -53,15 +53,26 @@ export const ProductCategoryForm: React.FC = ({ action }); return () => subscription.unsubscribe(); }, [watch]); + // Check slug pattern + // Only lowercase alphanumeric groups of characters separated by an hyphen + const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/g; // Form submit const onSubmit: SubmitHandler = (category: ProductCategory) => { switch (action) { case 'create': - console.log('create:', category); + ProductCategoryAPI.create(category).then(() => { + onSuccess(t('app.admin.store.product_category_form.create.success')); + }).catch((error) => { + onError(t('app.admin.store.product_category_form.create.error') + error); + }); break; case 'update': - console.log('update:', category); + ProductCategoryAPI.update(category).then(() => { + onSuccess(t('app.admin.store.product_category_form.update.success')); + }).catch((error) => { + onError(t('app.admin.store.product_category_form.update.error') + error); + }); break; case 'delete': ProductCategoryAPI.destroy(category.id).then(() => { @@ -84,13 +95,21 @@ export const ProductCategoryForm: React.FC = ({ action : <> + register={register} + rules={{ required: `${t('app.admin.store.product_category_form.required')}` }} + formState={formState} + label={t('app.admin.store.product_category_form.name')} + defaultValue={productCategory?.name || ''} /> Date: Wed, 20 Jul 2022 08:53:54 +0200 Subject: [PATCH 051/361] Add subfolder in store --- .../manage-product-category.tsx | 6 +++--- .../product-categories-tree.tsx} | 12 ++++++------ .../{ => categories}/product-categories.tsx | 18 +++++++++--------- .../{ => categories}/product-category-form.tsx | 12 ++++++------ .../modules/store/product-categories.scss | 2 +- 5 files changed, 25 insertions(+), 25 deletions(-) rename app/frontend/src/javascript/components/store/{ => categories}/manage-product-category.tsx (93%) rename app/frontend/src/javascript/components/store/{product-categories-list.tsx => categories/product-categories-tree.tsx} (75%) rename app/frontend/src/javascript/components/store/{ => categories}/product-categories.tsx (80%) rename app/frontend/src/javascript/components/store/{ => categories}/product-category-form.tsx (93%) diff --git a/app/frontend/src/javascript/components/store/manage-product-category.tsx b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx similarity index 93% rename from app/frontend/src/javascript/components/store/manage-product-category.tsx rename to app/frontend/src/javascript/components/store/categories/manage-product-category.tsx index 28b99b63f..068c5294f 100644 --- a/app/frontend/src/javascript/components/store/manage-product-category.tsx +++ b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ProductCategory } from '../../models/product-category'; -import { FabButton } from '../base/fab-button'; -import { FabModal, ModalSize } from '../base/fab-modal'; +import { ProductCategory } from '../../../models/product-category'; +import { FabButton } from '../../base/fab-button'; +import { FabModal, ModalSize } from '../../base/fab-modal'; import { ProductCategoryForm } from './product-category-form'; interface ManageProductCategoryProps { diff --git a/app/frontend/src/javascript/components/store/product-categories-list.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx similarity index 75% rename from app/frontend/src/javascript/components/store/product-categories-list.tsx rename to app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx index 0d0baf74e..132a2328c 100644 --- a/app/frontend/src/javascript/components/store/product-categories-list.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx @@ -1,21 +1,21 @@ import React from 'react'; -import { ProductCategory } from '../../models/product-category'; +import { ProductCategory } from '../../../models/product-category'; import { DotsSixVertical } from 'phosphor-react'; -import { FabButton } from '../base/fab-button'; +import { FabButton } from '../../base/fab-button'; import { ManageProductCategory } from './manage-product-category'; -interface ProductCategoriesListProps { +interface ProductCategoriesTreeProps { productCategories: Array, onSuccess: (message: string) => void, onError: (message: string) => void, } /** - * This component shows a Tree list of all Product's Categories + * This component shows a tree list of all Product's Categories */ -export const ProductCategoriesList: React.FC = ({ productCategories, onSuccess, onError }) => { +export const ProductCategoriesTree: React.FC = ({ productCategories, onSuccess, onError }) => { return ( -
+
{productCategories.map((category) => (
diff --git a/app/frontend/src/javascript/components/store/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx similarity index 80% rename from app/frontend/src/javascript/components/store/product-categories.tsx rename to app/frontend/src/javascript/components/store/categories/product-categories.tsx index 977bbe01f..0900f56fa 100644 --- a/app/frontend/src/javascript/components/store/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -1,13 +1,13 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ProductCategory } from '../../models/product-category'; -import ProductCategoryAPI from '../../api/product-category'; +import { ProductCategory } from '../../../models/product-category'; +import ProductCategoryAPI from '../../../api/product-category'; import { ManageProductCategory } from './manage-product-category'; -import { ProductCategoriesList } from './product-categories-list'; -import { FabAlert } from '../base/fab-alert'; -import { HtmlTranslate } from '../base/html-translate'; -import { IApplication } from '../../models/application'; -import { Loader } from '../base/loader'; +import { ProductCategoriesTree } from './product-categories-tree'; +import { FabAlert } from '../../base/fab-alert'; +import { HtmlTranslate } from '../../base/html-translate'; +import { IApplication } from '../../../models/application'; +import { Loader } from '../../base/loader'; import { react2angular } from 'react2angular'; declare const Application: IApplication; @@ -18,7 +18,7 @@ interface ProductCategoriesProps { } /** - * This component shows a list of all product categories and offer to manager them + * This component shows a tree list of all product categories and offer to manager them * by creating, deleting, modifying and reordering each product categories. */ const ProductCategories: React.FC = ({ onSuccess, onError }) => { @@ -61,7 +61,7 @@ const ProductCategories: React.FC = ({ onSuccess, onErro -
diff --git a/app/frontend/src/javascript/components/store/product-category-form.tsx b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx similarity index 93% rename from app/frontend/src/javascript/components/store/product-category-form.tsx rename to app/frontend/src/javascript/components/store/categories/product-category-form.tsx index 9f1f31b54..87136b03e 100644 --- a/app/frontend/src/javascript/components/store/product-category-form.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx @@ -2,12 +2,12 @@ import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useForm, SubmitHandler } from 'react-hook-form'; import slugify from 'slugify'; -import { FormInput } from '../form/form-input'; -import { FormSelect } from '../form/form-select'; -import { ProductCategory } from '../../models/product-category'; -import { FabButton } from '../base/fab-button'; -import { FabAlert } from '../base/fab-alert'; -import ProductCategoryAPI from '../../api/product-category'; +import { FormInput } from '../../form/form-input'; +import { FormSelect } from '../../form/form-select'; +import { ProductCategory } from '../../../models/product-category'; +import { FabButton } from '../../base/fab-button'; +import { FabAlert } from '../../base/fab-alert'; +import ProductCategoryAPI from '../../../api/product-category'; interface ProductCategoryFormProps { action: 'create' | 'update' | 'delete', diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 75963f5f2..3aab16990 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -39,7 +39,7 @@ } } - &-list { + &-tree { & > *:not(:last-of-type) { margin-bottom: 1.6rem; } From b53efc985045d5cf5bc9ba20edbe81b668ad3ae5 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 20 Jul 2022 11:54:46 +0200 Subject: [PATCH 052/361] change top position of product category to 0 --- app/models/product_category.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/product_category.rb b/app/models/product_category.rb index a17149171..8feb8afda 100644 --- a/app/models/product_category.rb +++ b/app/models/product_category.rb @@ -8,5 +8,5 @@ class ProductCategory < ApplicationRecord belongs_to :parent, class_name: 'ProductCategory' has_many :children, class_name: 'ProductCategory', foreign_key: :parent_id - acts_as_list scope: :parent + acts_as_list scope: :parent, top_of_list: 0 end From 69e2b3e1117c76518c6183937f9bca1000cb3644 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 13 Jul 2022 15:06:46 +0200 Subject: [PATCH 053/361] Product model/controller --- app/controllers/api/products_controller.rb | 52 +++++++++++++++++++ app/models/product.rb | 3 ++ app/policies/product_policy.rb | 16 ++++++ app/services/product_service.rb | 8 +++ app/views/api/products/_product.json.jbuilder | 3 ++ app/views/api/products/create.json.jbuilder | 3 ++ app/views/api/products/index.json.jbuilder | 5 ++ app/views/api/products/show.json.jbuilder | 3 ++ app/views/api/products/update.json.jbuilder | 3 ++ config/routes.rb | 2 + db/migrate/20220712153708_create_products.rb | 19 +++++++ ...60137_create_join_table_product_machine.rb | 8 +++ db/schema.rb | 23 ++++++++ 13 files changed, 148 insertions(+) create mode 100644 app/controllers/api/products_controller.rb create mode 100644 app/models/product.rb create mode 100644 app/policies/product_policy.rb create mode 100644 app/services/product_service.rb create mode 100644 app/views/api/products/_product.json.jbuilder create mode 100644 app/views/api/products/create.json.jbuilder create mode 100644 app/views/api/products/index.json.jbuilder create mode 100644 app/views/api/products/show.json.jbuilder create mode 100644 app/views/api/products/update.json.jbuilder create mode 100644 db/migrate/20220712153708_create_products.rb create mode 100644 db/migrate/20220712160137_create_join_table_product_machine.rb diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb new file mode 100644 index 000000000..b48777b5c --- /dev/null +++ b/app/controllers/api/products_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# API Controller for resources of type Product +# 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] + + def index + @products = ProductService.list + end + + def show; end + + def create + authorize Product + @product = Product.new(product_params) + if @product.save + render status: :created + else + render json: @product.errors.full_messages, status: :unprocessable_entity + end + end + + def update + authorize @product + + if @product.update(product_params) + render status: :ok + else + render json: @product.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + authorize @product + @product.destroy + head :no_content + end + + private + + def set_product + @product = Product.find(params[:id]) + end + + def product_params + params.require(:product).permit(:name, :slug, :sku, :description, :is_active, + :product_category_id, :amount, :quantity_min, + :low_stock_alert, :low_stock_threshold) + end +end diff --git a/app/models/product.rb b/app/models/product.rb new file mode 100644 index 000000000..48d439822 --- /dev/null +++ b/app/models/product.rb @@ -0,0 +1,3 @@ +class Product < ApplicationRecord + belongs_to :product_category +end diff --git a/app/policies/product_policy.rb b/app/policies/product_policy.rb new file mode 100644 index 000000000..f64026b79 --- /dev/null +++ b/app/policies/product_policy.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Check the access policies for API::ProductsController +class ProductPolicy < ApplicationPolicy + def create? + user.privileged? + end + + def update? + user.privileged? + end + + def destroy? + user.privileged? + end +end diff --git a/app/services/product_service.rb b/app/services/product_service.rb new file mode 100644 index 000000000..d31f61ae8 --- /dev/null +++ b/app/services/product_service.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Provides methods for Product +class ProductService + def self.list + Product.all + end +end diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder new file mode 100644 index 000000000..b18ee0374 --- /dev/null +++ b/app/views/api/products/_product.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :amount, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold diff --git a/app/views/api/products/create.json.jbuilder b/app/views/api/products/create.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/create.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/app/views/api/products/index.json.jbuilder b/app/views/api/products/index.json.jbuilder new file mode 100644 index 000000000..bc58aeb30 --- /dev/null +++ b/app/views/api/products/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.array! @products do |product| + json.partial! 'api/products/product', product: product +end diff --git a/app/views/api/products/show.json.jbuilder b/app/views/api/products/show.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/app/views/api/products/update.json.jbuilder b/app/views/api/products/update.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/update.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/config/routes.rb b/config/routes.rb index 6abcf4dca..e9dc37f22 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -154,6 +154,8 @@ Rails.application.routes.draw do patch 'position', on: :member end + resources :products + # for admin resources :trainings do get :availabilities, on: :member diff --git a/db/migrate/20220712153708_create_products.rb b/db/migrate/20220712153708_create_products.rb new file mode 100644 index 000000000..154e1a896 --- /dev/null +++ b/db/migrate/20220712153708_create_products.rb @@ -0,0 +1,19 @@ +class CreateProducts < ActiveRecord::Migration[5.2] + def change + create_table :products do |t| + t.string :name + t.string :slug + t.string :sku + t.text :description + t.boolean :is_active, default: false + t.belongs_to :product_category, foreign_key: true + t.integer :amount + t.integer :quantity_min + t.jsonb :stock, default: { internal: 0, external: 0 } + t.boolean :low_stock_alert, default: false + t.integer :low_stock_threshold + + t.timestamps + end + end +end diff --git a/db/migrate/20220712160137_create_join_table_product_machine.rb b/db/migrate/20220712160137_create_join_table_product_machine.rb new file mode 100644 index 000000000..874005fd0 --- /dev/null +++ b/db/migrate/20220712160137_create_join_table_product_machine.rb @@ -0,0 +1,8 @@ +class CreateJoinTableProductMachine < ActiveRecord::Migration[5.2] + def change + create_join_table :products, :machines do |t| + # t.index [:product_id, :machine_id] + # t.index [:machine_id, :product_id] + end + end +end diff --git a/db/schema.rb b/db/schema.rb index e65091513..1222e9bcf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -367,6 +367,11 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.index ["machine_id"], name: "index_machines_availabilities_on_machine_id" end + create_table "machines_products", id: false, force: :cascade do |t| + t.bigint "product_id", null: false + t.bigint "machine_id", null: false + end + create_table "notifications", id: :serial, force: :cascade do |t| t.integer "receiver_id" t.string "attached_object_type" @@ -591,6 +596,23 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.index ["parent_id"], name: "index_product_categories_on_parent_id" end + create_table "products", force: :cascade do |t| + t.string "name" + t.string "slug" + t.string "sku" + t.text "description" + t.boolean "is_active", default: false + t.bigint "product_category_id" + t.integer "amount" + t.integer "quantity_min" + t.jsonb "stock", default: {"external"=>0, "internal"=>0} + t.boolean "low_stock_alert", default: false + t.integer "low_stock_threshold" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["product_category_id"], name: "index_products_on_product_category_id" + end + create_table "profile_custom_fields", force: :cascade do |t| t.string "label" t.boolean "required", default: false @@ -1112,6 +1134,7 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do add_foreign_key "prepaid_packs", "groups" add_foreign_key "prices", "groups" add_foreign_key "prices", "plans" + add_foreign_key "products", "product_categories" add_foreign_key "project_steps", "projects" add_foreign_key "project_users", "projects" add_foreign_key "project_users", "users" From 6b805f15f1fa49f29bb4e0af7f125f4e34de0d7c Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 13 Jul 2022 19:34:38 +0200 Subject: [PATCH 054/361] products page in front --- app/frontend/src/javascript/api/product.ts | 30 ++++++++ .../components/store/products-list.tsx | 50 ++++++++++++ .../javascript/components/store/products.tsx | 77 +++++++++++++++++++ .../src/javascript/controllers/admin/store.js | 30 +++++++- .../src/javascript/controllers/main_nav.js | 2 +- app/frontend/src/javascript/models/product.ts | 24 ++++++ app/frontend/src/javascript/router.js | 17 ++++ app/frontend/templates/admin/store/index.html | 8 +- .../templates/admin/store/products.html | 2 +- config/locales/app.admin.en.yml | 7 +- config/locales/app.admin.fr.yml | 7 +- 11 files changed, 244 insertions(+), 10 deletions(-) create mode 100644 app/frontend/src/javascript/api/product.ts create mode 100644 app/frontend/src/javascript/components/store/products-list.tsx create mode 100644 app/frontend/src/javascript/components/store/products.tsx create mode 100644 app/frontend/src/javascript/models/product.ts diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts new file mode 100644 index 000000000..edb434c95 --- /dev/null +++ b/app/frontend/src/javascript/api/product.ts @@ -0,0 +1,30 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { Product } from '../models/product'; + +export default class ProductAPI { + static async index (): Promise> { + const res: AxiosResponse> = await apiClient.get('/api/products'); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/products/${id}`); + return res?.data; + } + + static async create (product: Product): Promise { + const res: AxiosResponse = await apiClient.post('/api/products', { product }); + return res?.data; + } + + static async update (product: Product): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/products/${product.id}`, { product }); + 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/products-list.tsx b/app/frontend/src/javascript/components/store/products-list.tsx new file mode 100644 index 000000000..60f05cd96 --- /dev/null +++ b/app/frontend/src/javascript/components/store/products-list.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { FabButton } from '../base/fab-button'; +import { Product } from '../../models/product'; + +interface ProductsListProps { + products: Array, + onEdit: (product: Product) => void, + onDelete: (productId: number) => void, +} + +/** + * This component shows a list of all Products + */ +export const ProductsList: React.FC = ({ products, onEdit, onDelete }) => { + /** + * Init the process of editing the given product + */ + const editProduct = (product: Product): () => void => { + return (): void => { + onEdit(product); + }; + }; + + /** + * Init the process of delete the given product + */ + const deleteProduct = (productId: number): () => void => { + return (): void => { + onDelete(productId); + }; + }; + + return ( +
+ {products.map((product) => ( +
+ {product.name} +
+ + + + + + +
+
+ ))} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx new file mode 100644 index 000000000..09c7e0912 --- /dev/null +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -0,0 +1,77 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { HtmlTranslate } from '../base/html-translate'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { FabAlert } from '../base/fab-alert'; +import { FabButton } from '../base/fab-button'; +import { ProductsList } from './products-list'; +import { Product } from '../../models/product'; +import ProductAPI from '../../api/product'; + +declare const Application: IApplication; + +interface ProductsProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component shows all Products and filter + */ +const Products: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const [products, setProducts] = useState>([]); + const [product, setProduct] = useState(null); + + useEffect(() => { + ProductAPI.index().then(data => { + setProducts(data); + }); + }, []); + + /** + * Open edit the product modal + */ + const editProduct = (product: Product) => { + setProduct(product); + }; + + /** + * Delete a product + */ + const deleteProduct = async (productId: number): Promise => { + try { + await ProductAPI.destroy(productId); + const data = await ProductAPI.index(); + setProducts(data); + onSuccess(t('app.admin.store.products.successfully_deleted')); + } catch (e) { + onError(t('app.admin.store.products.unable_to_delete') + e); + } + }; + + return ( +
+

{t('app.admin.store.products.all_products')}

+ {t('app.admin.store.products.create_a_product')} + +
+ ); +}; + +const ProductsWrapper: React.FC = ({ onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/controllers/admin/store.js b/app/frontend/src/javascript/controllers/admin/store.js index 76cac12da..f46365752 100644 --- a/app/frontend/src/javascript/controllers/admin/store.js +++ b/app/frontend/src/javascript/controllers/admin/store.js @@ -4,9 +4,35 @@ */ 'use strict'; -Application.Controllers.controller('AdminStoreController', ['$scope', 'CSRF', 'growl', - function ($scope, CSRF, growl) { +Application.Controllers.controller('AdminStoreController', ['$scope', 'CSRF', 'growl', '$state', + function ($scope, CSRF, growl, $state) { + /* PRIVATE SCOPE */ + // Map of tab state and index + const TABS = { + 'app.admin.store.settings': 0, + 'app.admin.store.products': 1, + 'app.admin.store.categories': 2, + 'app.admin.store.orders': 3 + }; + /* PUBLIC SCOPE */ + // default tab: products + $scope.tabs = { + active: TABS[$state.current.name] + }; + + /** + * Callback triggered in click tab + */ + $scope.selectTab = () => { + setTimeout(function () { + const currentTab = _.keys(TABS)[$scope.tabs.active]; + if (currentTab !== $state.current.name) { + $state.go(currentTab, { location: true, notify: false, reload: false }); + } + }); + }; + /** * Callback triggered in case of error */ diff --git a/app/frontend/src/javascript/controllers/main_nav.js b/app/frontend/src/javascript/controllers/main_nav.js index c2a672e02..ead43fda0 100644 --- a/app/frontend/src/javascript/controllers/main_nav.js +++ b/app/frontend/src/javascript/controllers/main_nav.js @@ -84,7 +84,7 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm authorizedRoles: ['admin', 'manager'] }, { - state: 'app.admin.store', + state: 'app.admin.store.products', linkText: 'app.public.common.manage_the_store', linkIcon: 'cart-plus', authorizedRoles: ['admin', 'manager'] diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts new file mode 100644 index 000000000..b5818c1f4 --- /dev/null +++ b/app/frontend/src/javascript/models/product.ts @@ -0,0 +1,24 @@ +export enum StockType { + internal = 'internal', + external = 'external' +} + +export interface Stock { + internal: number, + external: number, +} + +export interface Product { + id: number, + name: string, + slug: string, + sku: string, + description: string, + is_active: boolean, + product_category_id: number, + amount: number, + quantity_min: number, + stock: Stock, + low_stock_alert: boolean, + low_stock_threshold: number, +} diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index d86e624a6..da074335a 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -1105,6 +1105,7 @@ angular.module('application.router', ['ui.router']) }) .state('app.admin.store', { + abstract: true, url: '/admin/store', views: { 'main@': { @@ -1114,6 +1115,22 @@ angular.module('application.router', ['ui.router']) } }) + .state('app.admin.store.settings', { + url: '/settings' + }) + + .state('app.admin.store.products', { + url: '/products' + }) + + .state('app.admin.store.categories', { + url: '/categories' + }) + + .state('app.admin.store.orders', { + url: '/orders' + }) + // OpenAPI Clients .state('app.admin.open_api_clients', { url: '/open_api_clients', diff --git a/app/frontend/templates/admin/store/index.html b/app/frontend/templates/admin/store/index.html index cf591b0ec..0b7557e96 100644 --- a/app/frontend/templates/admin/store/index.html +++ b/app/frontend/templates/admin/store/index.html @@ -20,19 +20,19 @@
- + - + - + - + diff --git a/app/frontend/templates/admin/store/products.html b/app/frontend/templates/admin/store/products.html index c4db68bf6..e37bcce4f 100644 --- a/app/frontend/templates/admin/store/products.html +++ b/app/frontend/templates/admin/store/products.html @@ -1 +1 @@ -

Products page

+ diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index d9c24536e..dede5aae3 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1925,4 +1925,9 @@ en: success: "The category has been successfully deleted" save: "Save" required: "This field is required" - slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" \ No newline at end of file + slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + products: + all_products: "All products" + create_a_product: "Create a product" + successfully_deleted: "The product has been successfully deleted" + unable_to_delete: "Unable to delete the product: " diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 2bbcea278..991a429e4 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1925,4 +1925,9 @@ fr: success: "La catégorie a bien été supprimée" save: "Enregistrer" required: "This field is required" - slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" \ No newline at end of file + slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + products: + all_products: "Tous les produits" + create_a_product: "Créer un produit" + successfully_deleted: "Le produit a bien été supprimé" + unable_to_delete: "Impossible de supprimer le produit: " From 272cbf165c2febf986badc0fb13419be3ee2bec4 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Fri, 22 Jul 2022 18:48:28 +0200 Subject: [PATCH 055/361] create/edit product form --- app/controllers/api/products_controller.rb | 11 +- .../components/form/form-check-list.tsx | 99 +++++++++ .../components/store/edit-product.tsx | 56 +++++ .../components/store/new-product.tsx | 58 +++++ .../components/store/product-form.tsx | 199 ++++++++++++++++++ .../javascript/components/store/products.tsx | 16 +- .../controllers/admin/store_products.js | 47 +++++ app/frontend/src/javascript/models/product.ts | 9 +- app/frontend/src/javascript/router.js | 52 ++++- app/frontend/src/stylesheets/application.scss | 1 + .../modules/form/form-check-list.scss | 17 ++ .../templates/admin/store/product_edit.html | 35 +++ .../templates/admin/store/product_new.html | 35 +++ app/models/machine.rb | 1 + app/models/product.rb | 7 + app/views/api/products/_product.json.jbuilder | 3 +- config/locales/app.admin.en.yml | 23 ++ config/locales/app.admin.fr.yml | 23 ++ config/locales/app.shared.en.yml | 2 + config/locales/app.shared.fr.yml | 2 + db/migrate/20220712153708_create_products.rb | 2 + 21 files changed, 677 insertions(+), 21 deletions(-) create mode 100644 app/frontend/src/javascript/components/form/form-check-list.tsx create mode 100644 app/frontend/src/javascript/components/store/edit-product.tsx create mode 100644 app/frontend/src/javascript/components/store/new-product.tsx create mode 100644 app/frontend/src/javascript/components/store/product-form.tsx create mode 100644 app/frontend/src/javascript/controllers/admin/store_products.js create mode 100644 app/frontend/src/stylesheets/modules/form/form-check-list.scss create mode 100644 app/frontend/templates/admin/store/product_edit.html create mode 100644 app/frontend/templates/admin/store/product_new.html diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index b48777b5c..e411ce090 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[show update destroy] def index @products = ProductService.list @@ -15,6 +15,8 @@ class API::ProductsController < API::ApiController def create authorize Product @product = Product.new(product_params) + @product.amount = nil if @product.amount.zero? + @product.amount *= 100 if @product.amount.present? if @product.save render status: :created else @@ -25,7 +27,10 @@ class API::ProductsController < API::ApiController def update authorize @product - if @product.update(product_params) + product_parameters = product_params + product_parameters[:amount] = nil if product_parameters[:amount].zero? + product_parameters[:amount] = product_parameters[:amount] * 100 if product_parameters[:amount].present? + if @product.update(product_parameters) render status: :ok else render json: @product.errors.full_messages, status: :unprocessable_entity @@ -47,6 +52,6 @@ class API::ProductsController < API::ApiController def product_params params.require(:product).permit(:name, :slug, :sku, :description, :is_active, :product_category_id, :amount, :quantity_min, - :low_stock_alert, :low_stock_threshold) + :low_stock_alert, :low_stock_threshold, machine_ids: []) end end diff --git a/app/frontend/src/javascript/components/form/form-check-list.tsx b/app/frontend/src/javascript/components/form/form-check-list.tsx new file mode 100644 index 000000000..1299dcd56 --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-check-list.tsx @@ -0,0 +1,99 @@ +import React, { BaseSyntheticEvent } from 'react'; +import { Controller, Path, FieldPathValue } from 'react-hook-form'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FieldPath } from 'react-hook-form/dist/types/path'; +import { useTranslation } from 'react-i18next'; +import { UnpackNestedValue } from 'react-hook-form/dist/types'; +import { FormControlledComponent } from '../../models/form-component'; +import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item'; +import { FabButton } from '../base/fab-button'; + +/** + * Checklist Option format + */ +export type ChecklistOption = { value: TOptionValue, label: string }; + +interface FormCheckListProps extends FormControlledComponent, AbstractFormItemProps { + defaultValue?: Array, + options: Array>, + onChange?: (values: Array) => void, +} + +/** + * This component is a template for an check list component to use within React Hook Form + */ +export const FormCheckList = ({ id, control, label, tooltip, defaultValue, className, rules, disabled, error, warning, formState, onChange, options }: FormCheckListProps) => { + const { t } = useTranslation('shared'); + + /** + * Verify if the provided option is currently ticked + */ + const isChecked = (values: Array, option: ChecklistOption): boolean => { + return !!values?.includes(option.value); + }; + + /** + * Callback triggered when a checkbox is ticked or unticked. + */ + const toggleCheckbox = (option: ChecklistOption, values: Array = [], cb: (value: Array) => void) => { + return (event: BaseSyntheticEvent) => { + let newValues: Array = []; + if (event.target.checked) { + newValues = values.concat(option.value); + } else { + newValues = values.filter(v => v !== option.value); + } + cb(newValues); + if (typeof onChange === 'function') { + onChange(newValues); + } + }; + }; + + /** + * Callback triggered to select all options + */ + const allSelect = (cb: (value: Array) => void) => { + return () => { + const newValues: Array = options.map(o => o.value); + cb(newValues); + if (typeof onChange === 'function') { + onChange(newValues); + } + }; + }; + + // Compose classnames from props + const classNames = [ + `${className || ''}` + ].join(' '); + + return ( + + } + control={control} + defaultValue={defaultValue as UnpackNestedValue>>} + rules={rules} + render={({ field: { onChange, value } }) => { + return ( + <> +
+ {options.map((option, k) => { + return ( +
+ + +
+ ); + })} +
+ {t('app.shared.form_check_list.select_all')} + + ); + }} /> +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/edit-product.tsx b/app/frontend/src/javascript/components/store/edit-product.tsx new file mode 100644 index 000000000..62ccad66c --- /dev/null +++ b/app/frontend/src/javascript/components/store/edit-product.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { ProductForm } from './product-form'; +import { Product } from '../../models/product'; +import ProductAPI from '../../api/product'; + +declare const Application: IApplication; + +interface EditProductProps { + productId: number, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component show new product form + */ +const EditProduct: React.FC = ({ productId, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const [product, setProduct] = useState(); + + useEffect(() => { + ProductAPI.get(productId).then(data => { + setProduct(data); + }).catch(onError); + }, []); + + /** + * Success to save product and return to product list + */ + const saveProductSuccess = () => { + onSuccess(t('app.admin.store.edit_product.successfully_updated')); + window.location.href = '/#!/admin/store/products'; + }; + + if (product) { + return ( + + ); + } + return null; +}; + +const EditProductWrapper: React.FC = ({ productId, onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/new-product.tsx b/app/frontend/src/javascript/components/store/new-product.tsx new file mode 100644 index 000000000..e3f2ca4d2 --- /dev/null +++ b/app/frontend/src/javascript/components/store/new-product.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { ProductForm } from './product-form'; + +declare const Application: IApplication; + +interface NewProductProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component show new product form + */ +const NewProduct: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const product = { + id: undefined, + name: '', + slug: '', + sku: '', + description: '', + is_active: false, + quantity_min: 1, + stock: { + internal: 0, + external: 0 + }, + low_stock_alert: false, + machine_ids: [] + }; + + /** + * Success to save product and return to product list + */ + const saveProductSuccess = () => { + onSuccess(t('app.admin.store.new_product.successfully_created')); + window.location.href = '/#!/admin/store/products'; + }; + + return ( + + ); +}; + +const NewProductWrapper: React.FC = ({ onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx new file mode 100644 index 000000000..e4198bbd5 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -0,0 +1,199 @@ +import React, { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import slugify from 'slugify'; +import _ from 'lodash'; +import { HtmlTranslate } from '../base/html-translate'; +import { Product } from '../../models/product'; +import { FormInput } from '../form/form-input'; +import { FormSwitch } from '../form/form-switch'; +import { FormSelect } from '../form/form-select'; +import { FormCheckList } from '../form/form-check-list'; +import { FormRichText } from '../form/form-rich-text'; +import { FabButton } from '../base/fab-button'; +import { FabAlert } from '../base/fab-alert'; +import ProductCategoryAPI from '../../api/product-category'; +import MachineAPI from '../../api/machine'; +import ProductAPI from '../../api/product'; + +interface ProductFormProps { + product: Product, + title: string, + onSuccess: (product: Product) => void, + onError: (message: string) => void, +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +type selectOption = { value: number, label: string }; + +/** + * Option format, expected by checklist + */ +type checklistOption = { value: number, label: string }; + +/** + * Form component to create or update a product + */ +export const ProductForm: React.FC = ({ product, title, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const { handleSubmit, register, control, formState, setValue, reset } = useForm({ defaultValues: { ...product } }); + const [isActivePrice, setIsActivePrice] = useState(product.id && _.isFinite(product.amount) && product.amount > 0); + const [productCategories, setProductCategories] = useState([]); + const [machines, setMachines] = useState([]); + + useEffect(() => { + ProductCategoryAPI.index().then(data => { + setProductCategories(buildSelectOptions(data)); + }).catch(onError); + MachineAPI.index({ disabled: false }).then(data => { + setMachines(buildChecklistOptions(data)); + }).catch(onError); + }, []); + + /** + * Convert the provided array of items to the react-select format + */ + const buildSelectOptions = (items: Array<{ id?: number, name: string }>): Array => { + return items.map(t => { + return { value: t.id, label: t.name }; + }); + }; + + /** + * Convert the provided array of items to the checklist format + */ + const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array => { + return items.map(t => { + return { value: t.id, label: t.name }; + }); + }; + + /** + * Callback triggered when the name has changed. + */ + const handleNameChange = (event: React.ChangeEvent): void => { + const name = event.target.value; + const slug = slugify(name, { lower: true, strict: true }); + setValue('slug', slug); + }; + + /** + * Callback triggered when is active price has changed. + */ + const toggleIsActivePrice = (value: boolean) => { + if (!value) { + setValue('amount', null); + } + setIsActivePrice(value); + }; + + /** + * Callback triggered when the form is submitted: process with the product creation or update. + */ + const onSubmit = (event: React.FormEvent) => { + return handleSubmit((data: Product) => { + saveProduct(data); + })(event); + }; + + /** + * Call product creation or update api + */ + const saveProduct = (data: Product) => { + if (product.id) { + ProductAPI.update(data).then((res) => { + reset(res); + onSuccess(res); + }).catch(onError); + } else { + ProductAPI.create(data).then((res) => { + reset(res); + onSuccess(res); + }).catch(onError); + } + }; + + return ( + <> +

{title}

+ {t('app.admin.store.product_form.save')} +
+ + + + +
+

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

+ + {isActivePrice &&
+ + +
} +

{t('app.admin.store.product_form.assigning_category')}

+ + + + +

{t('app.admin.store.product_form.assigning_machines')}

+ + + + +

{t('app.admin.store.product_form.product_description')}

+ + + + +
+
+ {t('app.admin.store.product_form.save')} +
+ + + ); +}; diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 09c7e0912..7f60082a3 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -1,10 +1,8 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; -import { HtmlTranslate } from '../base/html-translate'; import { Loader } from '../base/loader'; import { IApplication } from '../../models/application'; -import { FabAlert } from '../base/fab-alert'; import { FabButton } from '../base/fab-button'; import { ProductsList } from './products-list'; import { Product } from '../../models/product'; @@ -24,7 +22,6 @@ const Products: React.FC = ({ onSuccess, onError }) => { const { t } = useTranslation('admin'); const [products, setProducts] = useState>([]); - const [product, setProduct] = useState(null); useEffect(() => { ProductAPI.index().then(data => { @@ -33,10 +30,10 @@ const Products: React.FC = ({ onSuccess, onError }) => { }, []); /** - * Open edit the product modal + * Goto edit product page */ const editProduct = (product: Product) => { - setProduct(product); + window.location.href = `/#!/admin/store/products/${product.id}/edit`; }; /** @@ -53,10 +50,17 @@ const Products: React.FC = ({ onSuccess, onError }) => { } }; + /** + * Goto new product page + */ + const newProduct = (): void => { + window.location.href = '/#!/admin/store/products/new'; + }; + return (

{t('app.admin.store.products.all_products')}

- {t('app.admin.store.products.create_a_product')} + {t('app.admin.store.products.create_a_product')} { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /** + * Click Callback triggered in case of back products list + */ + $scope.backProductsList = () => { + $state.go('app.admin.store.products'); + }; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index b5818c1f4..9038dbd5c 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -15,10 +15,11 @@ export interface Product { sku: string, description: string, is_active: boolean, - product_category_id: number, - amount: number, - quantity_min: number, + product_category_id?: number, + amount?: number, + quantity_min?: number, stock: Stock, low_stock_alert: boolean, - low_stock_threshold: number, + low_stock_threshold?: number, + machine_ids: number[], } diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index da074335a..df21a17b5 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -1106,7 +1106,11 @@ angular.module('application.router', ['ui.router']) .state('app.admin.store', { abstract: true, - url: '/admin/store', + url: '/admin/store' + }) + + .state('app.admin.store.settings', { + url: '/settings', views: { 'main@': { templateUrl: '/admin/store/index.html', @@ -1115,20 +1119,54 @@ angular.module('application.router', ['ui.router']) } }) - .state('app.admin.store.settings', { - url: '/settings' + .state('app.admin.store.products', { + url: '/products', + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } }) - .state('app.admin.store.products', { - url: '/products' + .state('app.admin.store.products_new', { + url: '/products/new', + views: { + 'main@': { + templateUrl: '/admin/store/product_new.html', + controller: 'AdminStoreProductController' + } + } + }) + + .state('app.admin.store.products_edit', { + url: '/products/:id/edit', + views: { + 'main@': { + templateUrl: '/admin/store/product_edit.html', + controller: 'AdminStoreProductController' + } + } }) .state('app.admin.store.categories', { - url: '/categories' + url: '/categories', + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } }) .state('app.admin.store.orders', { - url: '/orders' + url: '/orders', + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } }) // OpenAPI Clients diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 48fe7e9a3..b3543e447 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -38,6 +38,7 @@ @import "modules/form/form-input"; @import "modules/form/form-rich-text"; @import "modules/form/form-switch"; +@import "modules/form/form-check-list"; @import "modules/group/change-group"; @import "modules/machines/machine-card"; @import "modules/machines/machines-filters"; diff --git a/app/frontend/src/stylesheets/modules/form/form-check-list.scss b/app/frontend/src/stylesheets/modules/form/form-check-list.scss new file mode 100644 index 000000000..f2b255c7d --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-check-list.scss @@ -0,0 +1,17 @@ +.form-check-list { + position: relative; + + .form-item-field { + display: block !important; + } + + .checklist { + display: flex; + padding: 16px; + flex-wrap: wrap; + } + + .checklist-item { + flex: 0 0 33.333333%; + } +} diff --git a/app/frontend/templates/admin/store/product_edit.html b/app/frontend/templates/admin/store/product_edit.html new file mode 100644 index 000000000..0bdf21ca9 --- /dev/null +++ b/app/frontend/templates/admin/store/product_edit.html @@ -0,0 +1,35 @@ +
+
+
+
+ +
+
+
+
+

{{ 'app.admin.store.manage_the_store' }}

+
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+
diff --git a/app/frontend/templates/admin/store/product_new.html b/app/frontend/templates/admin/store/product_new.html new file mode 100644 index 000000000..eb61f3019 --- /dev/null +++ b/app/frontend/templates/admin/store/product_new.html @@ -0,0 +1,35 @@ +
+
+
+
+ +
+
+
+
+

{{ 'app.admin.store.manage_the_store' }}

+
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+
diff --git a/app/models/machine.rb b/app/models/machine.rb index e7d0eadde..da118eddf 100644 --- a/app/models/machine.rb +++ b/app/models/machine.rb @@ -29,6 +29,7 @@ class Machine < ApplicationRecord has_one :payment_gateway_object, as: :item + has_and_belongs_to_many :products after_create :create_statistic_subtype after_create :create_machine_prices diff --git a/app/models/product.rb b/app/models/product.rb index 48d439822..29d0e1697 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -1,3 +1,10 @@ +# frozen_string_literal: true + +# Product is a model for the merchandise hold information of product in store class Product < ApplicationRecord belongs_to :product_category + + has_and_belongs_to_many :machines + + validates_numericality_of :amount, greater_than: 0, allow_nil: true end diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder index b18ee0374..624f8e45d 100644 --- a/app/views/api/products/_product.json.jbuilder +++ b/app/views/api/products/_product.json.jbuilder @@ -1,3 +1,4 @@ # frozen_string_literal: true -json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :amount, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold +json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold, :machine_ids +json.amount product.amount / 100.0 if product.amount.present? diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index dede5aae3..95d249d95 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1899,6 +1899,7 @@ en: all_products: "All products" categories_of_store: "Store's categories" the_orders: "Orders" + back_products_list: "Back to products list" product_categories: title: "Categories" info: "Information:
Find below all the categories created. The categories are arranged on two levels maximum, you can arrange them with a drag and drop. The order of the categories will be identical on the public view and the list below. Please note that you can delete a category or a sub-category even if they are associated with products. The latter will be left without categories. If you delete a category that contains sub-categories, the latter will also be deleted. Make sure that your categories are well arranged and save your choice." @@ -1931,3 +1932,25 @@ en: create_a_product: "Create a product" successfully_deleted: "The product has been successfully deleted" unable_to_delete: "Unable to delete the product: " + new_product: + add_a_new_product: "Add a new product" + successfully_created: "The new product has been created." + edit_product: + successfully_updated: "The product has been updated." + product_form: + name: "Name of product" + sku: "Reference product (SKU)" + slug: "Name of URL" + is_show_in_store: "Available in the store" + is_active_price: "Activate the price" + price_and_rule_of_selling_product: "Price and rule for selling the product" + price: "Price of product" + quantity_min: "Minimum number of items for the shopping cart" + linking_product_to_category: "Linking this product to an existing category" + assigning_category: "Assigning a category" + assigning_category_info: "Information
You can only declare one category per product. If you assign this product to a sub-category, it will automatically be assigned to its parent category as well." + assigning_machines: "Assigning machines" + assigning_machines_info: "Information
You can link one or more machines from your fablab to your product, this product will then be subject to the filters on the catalogue view.
The machines selected below will be linked to the product." + product_description: "Product description" + product_description_info: "Information
This product description will be present in the product sheet. You have a few editorial styles at your disposal to create the product sheet." + save: "Save" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 991a429e4..fabc04045 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1899,6 +1899,7 @@ fr: all_products: "Tous les produits" categories_of_store: "Les catégories de la boutique" the_orders: "Les commandes" + back_products_list: "Retrounez à la liste" product_categories: title: "Les catégories" info: "Information:
Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveaux maximum, vous pouvez les agencer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. Veillez au bon agencement de vos catégories et sauvegarder votre choix." @@ -1931,3 +1932,25 @@ fr: create_a_product: "Créer un produit" successfully_deleted: "Le produit a bien été supprimé" unable_to_delete: "Impossible de supprimer le produit: " + new_product: + add_a_new_product: "Ajouter un nouveau produit" + successfully_created: "Le produit a bien été créée." + edit_product: + successfully_updated: "Le produit a bien été mise à jour." + product_form: + name: "Nom de produit" + sku: "Référence produit (SKU)" + slug: "Nom de l'URL" + is_show_in_store: "Visible dans la boutique" + is_active_price: "Activer le prix" + price_and_rule_of_selling_product: "Prix et règle de vente du produit" + price: "Prix du produit" + quantity_min: "Nombre d'article minimum pour la mise au panier" + linking_product_to_category: "Lier ce product à une catégorie exisante" + assigning_category: "Attribuer à une catégorie" + assigning_category_info: "Information
Vous ne pouvez déclarer qu'une catégorie par produit. Si vous attribuez ce produit à une sous catégorie, il sera attribué automatiquement aussi à sa catégorie parent." + assigning_machines: "Attribuer aux machines" + assigning_machines_info: "Information
Vous pouvez lier une ou plusieurs machines de votre fablab à votre produit, Ce produit sera alors assujetti aux filtres sur la vue catalogue.
Les machines sélectionnées ci-dessous seront liées au produit." + product_description: "Description du produit" + product_description_info: "Information
Cette description du produit sera présente dans la fiche du produit. Vous avez à disposition quelques styles rédactionnels pour créer la fiche du produit." + save: "Enregistrer" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 34724b6d0..14ff7ba51 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -550,3 +550,5 @@ en: validate_button: "Validate the new card" form_multi_select: create_label: "Add {VALUE}" + form_check_list: + select_all: "Select all" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index d39a36486..0827df858 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -550,3 +550,5 @@ fr: validate_button: "Valider la nouvelle carte" form_multi_select: create_label: "Ajouter {VALUE}" + form_check_list: + select_all: "Tout sélectionner" diff --git a/db/migrate/20220712153708_create_products.rb b/db/migrate/20220712153708_create_products.rb index 154e1a896..3876ca037 100644 --- a/db/migrate/20220712153708_create_products.rb +++ b/db/migrate/20220712153708_create_products.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateProducts < ActiveRecord::Migration[5.2] def change create_table :products do |t| From 19e3921dc42b6767d01b01da8e123ff51ee3051b Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 25 Jul 2022 10:26:01 +0200 Subject: [PATCH 056/361] add products relation in product's category --- app/models/product.rb | 2 ++ app/models/product_category.rb | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/models/product.rb b/app/models/product.rb index 29d0e1697..d4c26abc2 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -7,4 +7,6 @@ class Product < ApplicationRecord has_and_belongs_to_many :machines validates_numericality_of :amount, greater_than: 0, allow_nil: true + + scope :active, -> { where(is_active: true) } end diff --git a/app/models/product_category.rb b/app/models/product_category.rb index 8feb8afda..fdc492f83 100644 --- a/app/models/product_category.rb +++ b/app/models/product_category.rb @@ -8,5 +8,7 @@ class ProductCategory < ApplicationRecord belongs_to :parent, class_name: 'ProductCategory' has_many :children, class_name: 'ProductCategory', foreign_key: :parent_id + has_many :products + acts_as_list scope: :parent, top_of_list: 0 end From e096d95dcc7c71e5cde68aeb47f5ea2be767b698 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 25 Jul 2022 11:16:41 +0200 Subject: [PATCH 057/361] reset product_category_id to nil if product_category is removed --- app/services/product_category_service.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/services/product_category_service.rb b/app/services/product_category_service.rb index 4c4c2ed67..fbcc72cf8 100644 --- a/app/services/product_category_service.rb +++ b/app/services/product_category_service.rb @@ -7,7 +7,13 @@ class ProductCategoryService end def self.destroy(product_category) - ProductCategory.where(parent_id: product_category.id).destroy_all - product_category.destroy + ActiveRecord::Base.transaction do + sub_categories = ProductCategory.where(parent_id: product_category.id) + # remove product_category and sub-categories related id in product + Product.where(product_category_id: sub_categories.map(&:id).push(product_category.id)).update(product_category_id: nil) + # remove all sub-categories + sub_categories.destroy_all + product_category.destroy + end end end From 4f90cb5d8041cecf98ca5d106caa55a0c764c717 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 25 Jul 2022 16:29:01 +0200 Subject: [PATCH 058/361] update edit product comment --- app/frontend/src/javascript/components/store/edit-product.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend/src/javascript/components/store/edit-product.tsx b/app/frontend/src/javascript/components/store/edit-product.tsx index 62ccad66c..384f4d6a0 100644 --- a/app/frontend/src/javascript/components/store/edit-product.tsx +++ b/app/frontend/src/javascript/components/store/edit-product.tsx @@ -16,7 +16,7 @@ interface EditProductProps { } /** - * This component show new product form + * This component show edit product form */ const EditProduct: React.FC = ({ productId, onSuccess, onError }) => { const { t } = useTranslation('admin'); From 81cc8db0f5f00b38ac98bb4bd873f31f17ffd1f8 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 25 Jul 2022 19:42:24 +0200 Subject: [PATCH 059/361] Remove react-beautiful-dnd --- .../categories/product-categories-item.tsx | 41 +++++++++++++++++++ .../categories/product-category-form.tsx | 11 ++++- config/locales/app.admin.en.yml | 1 + 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 app/frontend/src/javascript/components/store/categories/product-categories-item.tsx diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx new file mode 100644 index 000000000..1615df658 --- /dev/null +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { ProductCategory } from '../../../models/product-category'; +import { ManageProductCategory } from './manage-product-category'; +import { FabButton } from '../../base/fab-button'; +import { DotsSixVertical } from 'phosphor-react'; + +interface ProductCategoriesItemProps { + productCategories: Array, + category: ProductCategory, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * Renders a draggable category item + */ +export const ProductCategoriesItem: React.FC = ({ productCategories, category, onSuccess, onError }) => { + return ( +
+
+

{category.name}

+ [count] +
+
+
+ + +
+
+ } className='draghandle' /> +
+
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/categories/product-category-form.tsx b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx index 87136b03e..2729d966e 100644 --- a/app/frontend/src/javascript/components/store/categories/product-category-form.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx @@ -32,15 +32,22 @@ export const ProductCategoryForm: React.FC = ({ action const { register, watch, setValue, control, handleSubmit, formState } = useForm({ defaultValues: { ...productCategory } }); // filter all first level product categorie - const parents = productCategories.filter(c => !c.parent_id); + let parents = productCategories.filter(c => !c.parent_id); + if (action === 'update') { + parents = parents.filter(c => c.id !== productCategory.id); + } /** * Convert all parents to the react-select format */ const buildOptions = (): Array => { - return parents.map(t => { + const options = parents.map(t => { return { value: t.id, label: t.name }; }); + if (action === 'update') { + options.unshift({ value: null, label: t('app.admin.store.product_category_form.no_parent') }); + } + return options; }; // Create slug from category's name diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 95d249d95..103e8af33 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1914,6 +1914,7 @@ en: name: "Name of category" slug: "Name of URL" select_parent_product_category: "Choose a parent category (N1)" + no_parent: "No parent" create: error: "Unable to create the category: " success: "The new category has been created." From 1d5141d0738d2006414464ad70831779bed8b512 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 28 Jul 2022 15:20:25 +0200 Subject: [PATCH 060/361] Temporary broken drag and drop --- .../categories/product-categories-item.tsx | 25 ++- .../categories/product-categories-tree.tsx | 205 +++++++++++++++--- .../store/categories/product-categories.tsx | 39 +++- .../modules/store/product-categories.scss | 16 +- package.json | 2 + yarn.lock | 36 +++ 6 files changed, 286 insertions(+), 37 deletions(-) diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx index 1615df658..9799eed14 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -1,12 +1,14 @@ import React from 'react'; import { ProductCategory } from '../../../models/product-category'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { ManageProductCategory } from './manage-product-category'; -import { FabButton } from '../../base/fab-button'; import { DotsSixVertical } from 'phosphor-react'; interface ProductCategoriesItemProps { productCategories: Array, category: ProductCategory, + isChild?: boolean, onSuccess: (message: string) => void, onError: (message: string) => void, } @@ -14,9 +16,22 @@ interface ProductCategoriesItemProps { /** * Renders a draggable category item */ -export const ProductCategoriesItem: React.FC = ({ productCategories, category, onSuccess, onError }) => { +export const ProductCategoriesItem: React.FC = ({ productCategories, category, isChild, onSuccess, onError }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition + } = useSortable({ id: category.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition + }; + return ( -
+

{category.name}

[count] @@ -33,7 +48,9 @@ export const ProductCategoriesItem: React.FC = ({ pr onSuccess={onSuccess} onError={onError} />
- } className='draghandle' /> +
diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx index 132a2328c..01b8797af 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx @@ -1,11 +1,14 @@ -import React from 'react'; +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React, { useEffect, useState } from 'react'; +import { useImmer } from 'use-immer'; import { ProductCategory } from '../../../models/product-category'; -import { DotsSixVertical } from 'phosphor-react'; -import { FabButton } from '../../base/fab-button'; -import { ManageProductCategory } from './manage-product-category'; +import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, DragMoveEvent } from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { ProductCategoriesItem } from './product-categories-item'; interface ProductCategoriesTreeProps { productCategories: Array, + onDnd: (list: Array) => void, onSuccess: (message: string) => void, onError: (message: string) => void, } @@ -13,30 +16,180 @@ interface ProductCategoriesTreeProps { /** * This component shows a tree list of all Product's Categories */ -export const ProductCategoriesTree: React.FC = ({ productCategories, onSuccess, onError }) => { +export const ProductCategoriesTree: React.FC = ({ productCategories, onDnd, onSuccess, onError }) => { + const [categoriesList, setCategoriesList] = useImmer(productCategories); + const [hiddenChildren, setHiddenChildren] = useState({}); + + // Initialize state from props, sorting list as a tree + useEffect(() => { + setCategoriesList(productCategories); + }, [productCategories]); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); + + /** + * On drag start + */ + const handleDragStart = ({ active }: DragMoveEvent) => { + hideChildren(active.id, categoriesList.findIndex(el => el.id === active.id)); + const activeChildren = categoriesList.filter(c => c.parent_id === active.id); + if (activeChildren.length) { + setHiddenChildren({ [active.id]: activeChildren }); + const activeIndex = categoriesList.findIndex(el => el.id === active.id); + const tmpList = [...categoriesList]; + tmpList.splice(activeIndex + 1, activeChildren.length); + setCategoriesList(tmpList); + } + }; + + /** + * On drag move + */ + const handleDragMove = ({ delta, over }: DragMoveEvent) => { + console.log(findCategory(over.id).name); + if (delta.x > 48) { + console.log('Child'); + } else { + console.log('Parent'); + } + }; + + /** + * Update categories list after an item was dropped + */ + + const handleDragEnd = ({ active, over }: DragMoveEvent) => { + let newOrder = [...categoriesList]; + + // si déplacé sur une autre catégorie… + if (active.id !== over.id) { + // liste d'ids des catégories visibles + const previousIdsOrder = over?.data.current.sortable.items; + // index dans previousIdsOrder de la catégorie déplacée + const oldIndex = active.data.current.sortable.index; + // index dans previousIdsOrder de la catégorie de réception + const newIndex = over.data.current.sortable.index; + // liste de catégories mise à jour après le drop + const newIdsOrder = arrayMove(previousIdsOrder, oldIndex, newIndex); + // id du parent de la catégorie de réception + const newParentId = categoriesList[newIndex].parent_id; + + // nouvelle liste de catégories classées par newIdsOrder + newOrder = newIdsOrder.map(sortedId => { + // catégorie courante du map retrouvée grâce à l'id + const categoryFromId = findCategory(sortedId); + // si catégorie courante = catégorie déplacée… + if (categoryFromId.id === active.id) { + // maj du parent + categoryFromId.parent_id = newParentId; + } + // retour de la catégorie courante + return categoryFromId; + }); + } + // insert siblings back + if (hiddenChildren[active.id]?.length) { + newOrder.splice(over.data.current.sortable.index + 1, 0, ...hiddenChildren[active.id]); + setHiddenChildren({ ...hiddenChildren, [active.id]: null }); + } + onDnd(newOrder); + }; + + /** + * Reset state if the drag was canceled + */ + const handleDragCancel = ({ active }: DragMoveEvent) => { + setHiddenChildren({ ...hiddenChildren, [active.id]: null }); + setCategoriesList(productCategories); + }; + + /** + * Hide children by their parent's id + */ + const hideChildren = (parentId, parentIndex) => { + const children = findChildren(parentId); + if (children?.length) { + const tmpList = [...categoriesList]; + tmpList.splice(parentIndex + 1, children.length); + setCategoriesList(tmpList); + } + }; + + /** + * Find a category by its id + */ + const findCategory = (id) => { + return categoriesList.find(c => c.id === id); + }; + /** + * Find the children categories of a parent category by its id + */ + const findChildren = (id) => { + const displayedChildren = categoriesList.filter(c => c.parent_id === id); + if (displayedChildren.length) { + return displayedChildren; + } + return hiddenChildren[id]; + }; + /** + * Find category's status by its id + * single | parent | child + */ + const categoryStatus = (id) => { + const c = findCategory(id); + if (!c.parent_id) { + if (findChildren(id)?.length) { + return 'parent'; + } + return 'single'; + } else { + return 'child'; + } + }; + + /** + * Translate visual order into categories data positions + */ + const indexToPosition = (sortedIds: number[]) => { + const sort = sortedIds.map(sortedId => categoriesList.find(el => el.id === sortedId)); + const newPositions = sort.map(c => { + if (typeof c.parent_id === 'number') { + const parentIndex = sort.findIndex(el => el.id === c.parent_id); + const currentIndex = sort.findIndex(el => el.id === c.id); + return { ...c, position: (currentIndex - parentIndex - 1) }; + } + return c; + }); + return newPositions; + }; + return ( -
- {productCategories.map((category) => ( -
-
-

{category.name}

- [count] -
-
-
- + +
+ {categoriesList + .map((category) => ( + - -
- } className='draghandle' /> -
+ category={category} + onSuccess={onSuccess} + onError={onError} + isChild={typeof category.parent_id === 'number'} + /> + ))}
- ))} -
+ + ); }; diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx index 0900f56fa..e7cae432f 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -5,6 +5,7 @@ import ProductCategoryAPI from '../../../api/product-category'; import { ManageProductCategory } from './manage-product-category'; import { ProductCategoriesTree } from './product-categories-tree'; import { FabAlert } from '../../base/fab-alert'; +import { FabButton } from '../../base/fab-button'; import { HtmlTranslate } from '../../base/html-translate'; import { IApplication } from '../../../models/application'; import { Loader } from '../../base/loader'; @@ -41,28 +42,58 @@ const ProductCategories: React.FC = ({ onSuccess, onErro refreshCategories(); }; + /** + * Update state after drop + */ + const handleDnd = (data: ProductCategory[]) => { + setProductCategories(data); + }; + /** * Refresh the list of categories */ const refreshCategories = () => { ProductCategoryAPI.index().then(data => { - setProductCategories(data); + // Translate ProductCategory.position to array index + const sortedCategories = data + .filter(c => !c.parent_id) + .sort((a, b) => a.position - b.position); + const childrenCategories = data + .filter(c => typeof c.parent_id === 'number') + .sort((a, b) => b.position - a.position); + childrenCategories.forEach(c => { + const parentIndex = sortedCategories.findIndex(i => i.id === c.parent_id); + sortedCategories.splice(parentIndex + 1, 0, c); + }); + setProductCategories(sortedCategories); }).catch((error) => onError(error)); }; + /** + * Save list's new order + */ + const handleSave = () => { + // TODO: index to position -> send to API + console.log('save order:', productCategories); + }; + return (

{t('app.admin.store.product_categories.title')}

- +
+ + Plop +
); diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 3aab16990..5aec87ae3 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -22,6 +22,16 @@ display: flex; justify-content: space-between; align-items: center; + .grpBtn { + display: flex; + & > *:not(:first-child) { margin-left: 2.4rem; } + .saveBtn { + background-color: var(--main); + color: var(--gray-soft-lightest); + border: none; + &:hover { opacity: 0.75; } + } + } h2 { margin: 0; @include title-lg; @@ -40,8 +50,8 @@ } &-tree { - & > *:not(:last-of-type) { - margin-bottom: 1.6rem; + & > *:not(:first-child) { + margin-top: 1.6rem; } } &-item { @@ -94,4 +104,4 @@ cursor: grab; } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index baadafef9..ff8113ae1 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "@babel/preset-typescript": "^7.16.7", "@babel/runtime": "^7.17.2", "@claviska/jquery-minicolors": "^2.3.5", + "@dnd-kit/core": "^6.0.5", + "@dnd-kit/sortable": "^7.0.1", "@fortawesome/fontawesome-free": "5.14.0", "@lyracom/embedded-form-glue": "^0.3.3", "@stripe/react-stripe-js": "^1.4.0", diff --git a/yarn.lock b/yarn.lock index 1f2827b84..968221b91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1488,6 +1488,37 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz#d5e0706cf8c6acd8c6032f8d54070af261bbbb2f" integrity sha512-ws57AidsDvREKrZKYffXddNkyaF14iHNHm8VQnZH6t99E8gczjNN0GpvcGny0imC80yQ0tHz1xVUKk/KFQSUyA== +"@dnd-kit/accessibility@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz#3ccbefdfca595b0a23a5dc57d3de96bc6935641c" + integrity sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg== + dependencies: + tslib "^2.0.0" + +"@dnd-kit/core@^6.0.5": + version "6.0.5" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.0.5.tgz#5670ad0dcc83cd51dbf2fa8c6a5c8af4ac0c1989" + integrity sha512-3nL+Zy5cT+1XwsWdlXIvGIFvbuocMyB4NBxTN74DeBaBqeWdH9JsnKwQv7buZQgAHmAH+eIENfS1ginkvW6bCw== + dependencies: + "@dnd-kit/accessibility" "^3.0.0" + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/sortable@^7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-7.0.1.tgz#99c6012bbab4d8bb726c0eef7b921a338c404fdb" + integrity sha512-n77qAzJQtMMywu25sJzhz3gsHnDOUlEjTtnRl8A87rWIhnu32zuP+7zmFjwGgvqfXmRufqiHOSlH7JPC/tnJ8Q== + dependencies: + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/utilities@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.0.tgz#b3e956ea63a1347c9d0e1316b037ddcc6140acda" + integrity sha512-h65/pn2IPCCIWwdlR2BMLqRkDxpTEONA+HQW3n765HBijLYGyrnTCLa2YQt8VVjjSQD6EfFlTE6aS2Q/b6nb2g== + dependencies: + tslib "^2.0.0" + "@emotion/babel-plugin@^11.7.1": version "11.9.2" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.9.2.tgz#723b6d394c89fb2ef782229d92ba95a740576e95" @@ -7452,6 +7483,11 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tslib@^2.0.3, tslib@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" From ab6d91fd12cd58917c4fd217157daf4cf53b1dab Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 27 Jul 2022 15:35:57 +0200 Subject: [PATCH 061/361] (quality) rename check-list to checklist and added an uncheck all button --- ...form-check-list.tsx => form-checklist.tsx} | 46 +++++++++++-------- .../components/store/product-form.tsx | 4 +- app/frontend/src/stylesheets/application.scss | 2 +- .../modules/form/form-check-list.scss | 17 ------- .../modules/form/form-checklist.scss | 28 +++++++++++ config/locales/app.shared.en.yml | 3 +- 6 files changed, 61 insertions(+), 39 deletions(-) rename app/frontend/src/javascript/components/form/{form-check-list.tsx => form-checklist.tsx} (67%) delete mode 100644 app/frontend/src/stylesheets/modules/form/form-check-list.scss create mode 100644 app/frontend/src/stylesheets/modules/form/form-checklist.scss diff --git a/app/frontend/src/javascript/components/form/form-check-list.tsx b/app/frontend/src/javascript/components/form/form-checklist.tsx similarity index 67% rename from app/frontend/src/javascript/components/form/form-check-list.tsx rename to app/frontend/src/javascript/components/form/form-checklist.tsx index 1299dcd56..63914e2e7 100644 --- a/app/frontend/src/javascript/components/form/form-check-list.tsx +++ b/app/frontend/src/javascript/components/form/form-checklist.tsx @@ -1,4 +1,4 @@ -import React, { BaseSyntheticEvent } from 'react'; +import React from 'react'; import { Controller, Path, FieldPathValue } from 'react-hook-form'; import { FieldValues } from 'react-hook-form/dist/types/fields'; import { FieldPath } from 'react-hook-form/dist/types/path'; @@ -13,16 +13,16 @@ import { FabButton } from '../base/fab-button'; */ export type ChecklistOption = { value: TOptionValue, label: string }; -interface FormCheckListProps extends FormControlledComponent, AbstractFormItemProps { +interface FormChecklistProps extends FormControlledComponent, AbstractFormItemProps { defaultValue?: Array, options: Array>, onChange?: (values: Array) => void, } /** - * This component is a template for an check list component to use within React Hook Form + * This component is a template for a checklist component to use within React Hook Form */ -export const FormCheckList = ({ id, control, label, tooltip, defaultValue, className, rules, disabled, error, warning, formState, onChange, options }: FormCheckListProps) => { +export const FormChecklist = ({ id, control, label, tooltip, defaultValue, className, rules, disabled, error, warning, formState, onChange, options }: FormChecklistProps) => { const { t } = useTranslation('shared'); /** @@ -35,15 +35,15 @@ export const FormCheckList = , values: Array = [], cb: (value: Array) => void) => { - return (event: BaseSyntheticEvent) => { + const toggleCheckbox = (option: ChecklistOption, rhfValues: Array = [], rhfCallback: (value: Array) => void) => { + return (event: React.ChangeEvent) => { let newValues: Array = []; if (event.target.checked) { - newValues = values.concat(option.value); + newValues = rhfValues.concat(option.value); } else { - newValues = values.filter(v => v !== option.value); + newValues = rhfValues.filter(v => v !== option.value); } - cb(newValues); + rhfCallback(newValues); if (typeof onChange === 'function') { onChange(newValues); } @@ -51,26 +51,33 @@ export const FormCheckList = ) => void) => { + const selectAll = (rhfCallback: (value: Array) => void) => { return () => { const newValues: Array = options.map(o => o.value); - cb(newValues); + rhfCallback(newValues); if (typeof onChange === 'function') { onChange(newValues); } }; }; - // Compose classnames from props - const classNames = [ - `${className || ''}` - ].join(' '); + /** + * Mark all options as non-selected + */ + const unselectAll = (rhfCallback: (value: Array) => void) => { + return () => { + rhfCallback([]); + if (typeof onChange === 'function') { + onChange([]); + } + }; + }; return ( } @@ -90,7 +97,10 @@ export const FormCheckList = - {t('app.shared.form_check_list.select_all')} +
+ {t('app.shared.form_checklist.select_all')} + {t('app.shared.form_checklist.unselect_all')} +
); }} /> diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index e4198bbd5..cb388da7f 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -8,7 +8,7 @@ import { Product } from '../../models/product'; import { FormInput } from '../form/form-input'; import { FormSwitch } from '../form/form-switch'; import { FormSelect } from '../form/form-select'; -import { FormCheckList } from '../form/form-check-list'; +import { FormChecklist } from '../form/form-checklist'; import { FormRichText } from '../form/form-rich-text'; import { FabButton } from '../base/fab-button'; import { FabAlert } from '../base/fab-alert'; @@ -177,7 +177,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc - diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index b3543e447..6f01cd5cd 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -38,7 +38,7 @@ @import "modules/form/form-input"; @import "modules/form/form-rich-text"; @import "modules/form/form-switch"; -@import "modules/form/form-check-list"; +@import "modules/form/form-checklist"; @import "modules/group/change-group"; @import "modules/machines/machine-card"; @import "modules/machines/machines-filters"; diff --git a/app/frontend/src/stylesheets/modules/form/form-check-list.scss b/app/frontend/src/stylesheets/modules/form/form-check-list.scss deleted file mode 100644 index f2b255c7d..000000000 --- a/app/frontend/src/stylesheets/modules/form/form-check-list.scss +++ /dev/null @@ -1,17 +0,0 @@ -.form-check-list { - position: relative; - - .form-item-field { - display: block !important; - } - - .checklist { - display: flex; - padding: 16px; - flex-wrap: wrap; - } - - .checklist-item { - flex: 0 0 33.333333%; - } -} diff --git a/app/frontend/src/stylesheets/modules/form/form-checklist.scss b/app/frontend/src/stylesheets/modules/form/form-checklist.scss new file mode 100644 index 000000000..13b058739 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-checklist.scss @@ -0,0 +1,28 @@ +.form-checklist { + position: relative; + + .form-item-field { + display: block !important; + } + + .checklist { + display: flex; + padding: 16px; + flex-wrap: wrap; + + .checklist-item { + flex: 0 0 33.333333%; + + & > input { + margin-right: 1em; + } + } + } + + .actions { + display: flex; + justify-content: space-evenly; + width: 50%; + margin: auto auto 1.2em; + } +} diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 14ff7ba51..123885afa 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -550,5 +550,6 @@ en: validate_button: "Validate the new card" form_multi_select: create_label: "Add {VALUE}" - form_check_list: + form_checklist: select_all: "Select all" + unselect_all: "Unselect all" From 4e65396e7e85dcf1ee63cb40a6e9faf3c94f7003 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 28 Jul 2022 18:13:18 +0200 Subject: [PATCH 062/361] (wip) Style product's components --- .../components/form/form-checklist.tsx | 4 +- .../store/categories/product-categories.tsx | 2 +- .../components/store/edit-product.tsx | 4 +- .../components/store/new-product.tsx | 4 +- .../components/store/product-form.tsx | 89 ++++++++++++------- .../javascript/components/store/products.tsx | 10 ++- app/frontend/src/stylesheets/application.scss | 2 + .../stylesheets/modules/base/fab-button.scss | 13 +++ .../modules/base/fab-output-copy.scss | 2 +- .../modules/form/form-checklist.scss | 30 +++---- .../stylesheets/modules/store/_utilities.scss | 14 +++ .../modules/store/product-categories.scss | 39 +++----- .../stylesheets/modules/store/products.scss | 73 +++++++++++++++ .../src/stylesheets/variables/decoration.scss | 1 + .../templates/admin/store/product_edit.html | 4 +- .../templates/admin/store/product_new.html | 4 +- 16 files changed, 207 insertions(+), 88 deletions(-) create mode 100644 app/frontend/src/stylesheets/modules/store/_utilities.scss create mode 100644 app/frontend/src/stylesheets/modules/store/products.scss diff --git a/app/frontend/src/javascript/components/form/form-checklist.tsx b/app/frontend/src/javascript/components/form/form-checklist.tsx index 63914e2e7..ac457a248 100644 --- a/app/frontend/src/javascript/components/form/form-checklist.tsx +++ b/app/frontend/src/javascript/components/form/form-checklist.tsx @@ -98,8 +98,8 @@ export const FormChecklist =
- {t('app.shared.form_checklist.select_all')} - {t('app.shared.form_checklist.unselect_all')} + {t('app.shared.form_checklist.select_all')} + {t('app.shared.form_checklist.unselect_all')}
); diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx index e7cae432f..449a965fd 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -85,7 +85,7 @@ const ProductCategories: React.FC = ({ onSuccess, onErro - Plop + Plop
diff --git a/app/frontend/src/javascript/components/store/edit-product.tsx b/app/frontend/src/javascript/components/store/edit-product.tsx index 384f4d6a0..27ef76e1f 100644 --- a/app/frontend/src/javascript/components/store/edit-product.tsx +++ b/app/frontend/src/javascript/components/store/edit-product.tsx @@ -39,7 +39,9 @@ const EditProduct: React.FC = ({ productId, onSuccess, onError if (product) { return ( - +
+ +
); } return null; diff --git a/app/frontend/src/javascript/components/store/new-product.tsx b/app/frontend/src/javascript/components/store/new-product.tsx index e3f2ca4d2..44c336ffa 100644 --- a/app/frontend/src/javascript/components/store/new-product.tsx +++ b/app/frontend/src/javascript/components/store/new-product.tsx @@ -43,7 +43,9 @@ const NewProduct: React.FC = ({ onSuccess, onError }) => { }; return ( - +
+ +
); }; diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index cb388da7f..b4caa92a3 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -119,19 +119,26 @@ export const ProductForm: React.FC = ({ product, title, onSucc return ( <> -

{title}

- {t('app.admin.store.product_form.save')} +
+

{title}

+
+ {t('app.admin.store.product_form.save')} +
+
- - +
+ + +
= ({ product, title, onSucc id="is_active" formState={formState} label={t('app.admin.store.product_form.is_show_in_store')} /> + +
+
-

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

- +
+

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

+ +
{isActivePrice &&
- - +
+ + +
} + +
+

{t('app.admin.store.product_form.assigning_category')}

@@ -173,6 +190,9 @@ export const ProductForm: React.FC = ({ product, title, onSucc id="product_category_id" formState={formState} label={t('app.admin.store.product_form.linking_product_to_category')} /> + +
+

{t('app.admin.store.product_form.assigning_machines')}

@@ -181,6 +201,9 @@ export const ProductForm: React.FC = ({ product, title, onSucc control={control} id="machine_ids" formState={formState} /> + +
+

{t('app.admin.store.product_form.product_description')}

@@ -191,7 +214,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc id="description" />
- {t('app.admin.store.product_form.save')} + {t('app.admin.store.product_form.save')}
diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 7f60082a3..5abf35b0f 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -58,9 +58,13 @@ const Products: React.FC = ({ onSuccess, onError }) => { }; return ( -
-

{t('app.admin.store.products.all_products')}

- {t('app.admin.store.products.create_a_product')} +
+
+

{t('app.admin.store.products.all_products')}

+
+ {t('app.admin.store.products.create_a_product')} +
+
input { - background-color: var(--gray-soft); + background-color: var(--gray-soft-dark); border-top-right-radius: 0; border-bottom-right-radius: 0; } diff --git a/app/frontend/src/stylesheets/modules/form/form-checklist.scss b/app/frontend/src/stylesheets/modules/form/form-checklist.scss index 13b058739..4703c2313 100644 --- a/app/frontend/src/stylesheets/modules/form/form-checklist.scss +++ b/app/frontend/src/stylesheets/modules/form/form-checklist.scss @@ -1,28 +1,28 @@ .form-checklist { - position: relative; - .form-item-field { - display: block !important; + display: flex; + flex-direction: column; + border: none; } .checklist { - display: flex; - padding: 16px; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 1.6rem 3.2rem; - .checklist-item { - flex: 0 0 33.333333%; - - & > input { - margin-right: 1em; - } + .checklist-item input { + margin-right: 1em; } } .actions { + align-self: flex-end; + margin: 2.4rem 0; display: flex; - justify-content: space-evenly; - width: 50%; - margin: auto auto 1.2em; + justify-content: flex-end; + align-items: center; + & > *:not(:first-child) { + margin-left: 1.6rem; + } } } diff --git a/app/frontend/src/stylesheets/modules/store/_utilities.scss b/app/frontend/src/stylesheets/modules/store/_utilities.scss new file mode 100644 index 000000000..12cff16e3 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/_utilities.scss @@ -0,0 +1,14 @@ +@mixin btn { + width: 4rem; + height: 4rem; + display: inline-flex; + justify-content: center; + align-items: center; + padding: 0; + background: none; + border: none; + &:active { + color: currentColor; + box-shadow: none; + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 5aec87ae3..bfc60dfbe 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -1,18 +1,3 @@ -@mixin btn { - width: 4rem; - height: 4rem; - display: inline-flex; - justify-content: center; - align-items: center; - padding: 0; - background: none; - border: none; - &:active { - color: currentColor; - box-shadow: none; - } -} - .product-categories { max-width: 1300px; margin: 0 auto; @@ -25,28 +10,28 @@ .grpBtn { display: flex; & > *:not(:first-child) { margin-left: 2.4rem; } - .saveBtn { - background-color: var(--main); + .create-button { + background-color: var(--gray-hard-darkest); + border-color: var(--gray-hard-darkest); color: var(--gray-soft-lightest); - border: none; - &:hover { opacity: 0.75; } + &:hover { + background-color: var(--gray-hard-light); + border-color: var(--gray-hard-light); + } } } h2 { margin: 0; @include title-lg; - color: var(--gray-hard-darkest); + color: var(--gray-hard-darkest) !important; } } - .create-button { - background-color: var(--gray-hard-darkest); - border-color: var(--gray-hard-darkest); + .main-action-btn { + background-color: var(--main); color: var(--gray-soft-lightest); - &:hover { - background-color: var(--gray-hard-light); - border-color: var(--gray-hard-light); - } + border: none; + &:hover { opacity: 0.75; } } &-tree { diff --git a/app/frontend/src/stylesheets/modules/store/products.scss b/app/frontend/src/stylesheets/modules/store/products.scss new file mode 100644 index 000000000..1a52e6eb8 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/products.scss @@ -0,0 +1,73 @@ +.products, +.new-product, +.edit-product { + margin: 0 auto; + padding-bottom: 6rem; + + .back-btn { + margin: 2.4rem 0; + padding: 0.4rem 0.8rem; + display: inline-flex; + align-items: center; + background-color: var(--gray-soft-darkest); + border-radius: var(--border-radius-sm); + color: var(--gray-soft-lightest); + i { margin-right: 0.8rem; } + + &:hover { + background-color: var(--gray-hard-lightest); + cursor: pointer; + } + } + + header { + padding: 2.4rem 0; + display: flex; + justify-content: space-between; + align-items: center; + .grpBtn { + display: flex; + & > *:not(:first-child) { margin-left: 2.4rem; } + } + h2 { + margin: 0; + @include title-lg; + color: var(--gray-hard-darkest) !important; + } + } + + .main-action-btn { + background-color: var(--main); + color: var(--gray-soft-lightest); + border: none; + &:hover { opacity: 0.75; } + } + + .main-actions { + display: flex; + justify-content: center; + align-items: center; + & > *:not(:first-child) { + margin-left: 1.6rem; + } + } +} + +.products { + max-width: 1600px; +} + +.new-product, +.edit-product { + max-width: 1300px; + + .product-form { + .grp { + display: flex; + gap: 2.4rem 3.2rem; + .span-7 { + min-width: 70%; + } + } + } +} diff --git a/app/frontend/src/stylesheets/variables/decoration.scss b/app/frontend/src/stylesheets/variables/decoration.scss index 29a009f81..156514a71 100644 --- a/app/frontend/src/stylesheets/variables/decoration.scss +++ b/app/frontend/src/stylesheets/variables/decoration.scss @@ -1,4 +1,5 @@ :root { --border-radius: 8px; + --border-radius-sm: 4px; --shadow: 0 0 10px rgba(39, 32, 32, 0.25); } \ No newline at end of file diff --git a/app/frontend/templates/admin/store/product_edit.html b/app/frontend/templates/admin/store/product_edit.html index 0bdf21ca9..f098a8b92 100644 --- a/app/frontend/templates/admin/store/product_edit.html +++ b/app/frontend/templates/admin/store/product_edit.html @@ -14,11 +14,11 @@
-
+
- + {{ 'app.admin.store.back_products_list' }} diff --git a/app/frontend/templates/admin/store/product_new.html b/app/frontend/templates/admin/store/product_new.html index eb61f3019..db9dae230 100644 --- a/app/frontend/templates/admin/store/product_new.html +++ b/app/frontend/templates/admin/store/product_new.html @@ -14,11 +14,11 @@
-
+
- + {{ 'app.admin.store.back_products_list' }} From 5e61e9c4090bc67ad7d93b879ed850824c17082f Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 28 Jul 2022 22:21:23 +0200 Subject: [PATCH 063/361] Fix save-btn color --- .../stylesheets/modules/settings/boolean-setting.scss | 10 +++++++--- .../modules/settings/user-validation-setting.scss | 10 +++++++--- .../src/stylesheets/modules/socials/fab-socials.scss | 8 ++++++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss b/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss index bf8d86c96..04f032429 100644 --- a/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss +++ b/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss @@ -10,9 +10,13 @@ vertical-align: middle; } .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); - color: var(--secondary-text-color); margin-left: 15px; + background-color: var(--secondary); + border-color: var(--secondary); + color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss b/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss index ec368ce3d..75b1c5f3f 100644 --- a/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss +++ b/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss @@ -1,8 +1,12 @@ .user-validation-setting { .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); - color: var(--secondary-text-color); margin-top: 15px; + background-color: var(--secondary); + border-color: var(--secondary); + color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/socials/fab-socials.scss b/app/frontend/src/stylesheets/modules/socials/fab-socials.scss index 4404ae008..44005e290 100644 --- a/app/frontend/src/stylesheets/modules/socials/fab-socials.scss +++ b/app/frontend/src/stylesheets/modules/socials/fab-socials.scss @@ -1,7 +1,11 @@ .fab-socials { .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); + background-color: var(--secondary); + border-color: var(--secondary); color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } From be6ba8deffffcfd023f3916f7f17535acc0e984e Mon Sep 17 00:00:00 2001 From: vincent Date: Fri, 29 Jul 2022 10:56:15 +0200 Subject: [PATCH 064/361] Fix button color + standardise class names --- .../store/categories/manage-product-category.tsx | 4 ++-- .../store/categories/product-categories-item.tsx | 2 +- .../modules/store/product-categories.scss | 8 ++++---- .../supporting-documents-files.scss | 12 ++++++++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx index 068c5294f..bf6be5028 100644 --- a/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx +++ b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx @@ -54,12 +54,12 @@ export const ManageProductCategory: React.FC = ({ pr case 'update': return (} - className="edit-button" + className="edit-btn" onClick={toggleModal} />); case 'delete': return (} - className="delete-button" + className="delete-btn" onClick={toggleModal} />); } }; diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx index 9799eed14..ac0bed4cb 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -36,7 +36,7 @@ export const ProductCategoriesItem: React.FC = ({ pr

{category.name}

[count]
-
+
Date: Fri, 29 Jul 2022 10:58:03 +0200 Subject: [PATCH 065/361] (wip) Style products list and form --- .../components/store/product-form.tsx | 32 ++-- .../components/store/products-list.tsx | 28 ++-- .../javascript/components/store/products.tsx | 45 +++++- .../stylesheets/modules/base/fab-button.scss | 11 ++ .../modules/form/form-checklist.scss | 2 +- .../stylesheets/modules/store/products.scss | 149 +++++++++++++++++- app/frontend/templates/admin/store/index.html | 2 +- .../templates/admin/store/product_edit.html | 2 +- .../templates/admin/store/product_new.html | 2 +- 9 files changed, 236 insertions(+), 37 deletions(-) diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index b4caa92a3..e00f38f79 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -126,7 +126,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc
-
+
= ({ product, title, onSucc + label={t('app.admin.store.product_form.sku')} + className='span-3' />
- - + + label={t('app.admin.store.product_form.slug')} + className='span-7' /> + +

-
+

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

+ onChange={toggleIsActivePrice} + className='span-3' />
{isActivePrice &&
-
+
= ({ products, onEdit, on }; return ( -
+ <> {products.map((product) => ( -
- {product.name} -
- - - - - - +
+
+ +

{product.name}

+
+
+
+
+ + + + + + +
))} -
+ ); }; diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 5abf35b0f..6d27af5b1 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -65,11 +65,46 @@ const Products: React.FC = ({ onSuccess, onError }) => { {t('app.admin.store.products.create_a_product')}
- +
+
+
+

Filtrer

+
+ Clear +
+
+
+
+
+
+

Result count: {products.length}

+
+
+
+

Display options:

+
+
+ +
+
+
+
+
+

feature name

+ +
+
+

long feature name

+ +
+
+ +
+
); }; diff --git a/app/frontend/src/stylesheets/modules/base/fab-button.scss b/app/frontend/src/stylesheets/modules/base/fab-button.scss index cb661590a..5bf2ece3f 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-button.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-button.scss @@ -62,4 +62,15 @@ opacity: 0.75; } } + &.is-black { + border-color: var(--gray-hard-darkest); + background-color: var(--gray-hard-darkest); + color: var(--gray-soft-lightest); + &:hover { + border-color: var(--gray-hard-darkest); + background-color: var(--gray-hard-darkest); + color: var(--gray-soft-lightest); + opacity: 0.75; + } + } } diff --git a/app/frontend/src/stylesheets/modules/form/form-checklist.scss b/app/frontend/src/stylesheets/modules/form/form-checklist.scss index 4703c2313..20721b0c5 100644 --- a/app/frontend/src/stylesheets/modules/form/form-checklist.scss +++ b/app/frontend/src/stylesheets/modules/form/form-checklist.scss @@ -7,7 +7,7 @@ .checklist { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.6rem 3.2rem; .checklist-item input { diff --git a/app/frontend/src/stylesheets/modules/store/products.scss b/app/frontend/src/stylesheets/modules/store/products.scss index 1a52e6eb8..05f4d59e6 100644 --- a/app/frontend/src/stylesheets/modules/store/products.scss +++ b/app/frontend/src/stylesheets/modules/store/products.scss @@ -34,6 +34,19 @@ @include title-lg; color: var(--gray-hard-darkest) !important; } + h3 { + margin: 0; + @include text-lg(600); + color: var(--gray-hard-darkest) !important; + } + } + + .layout { + display: flex; + align-items: flex-end; + gap: 0 3.2rem; + .span-7 { flex: 1 1 70%; } + .span-3 { flex: 1 1 30%; } } .main-action-btn { @@ -55,18 +68,146 @@ .products { max-width: 1600px; + + .layout { + align-items: flex-start; + } + + &-filters { + padding-top: 1.6rem; + border-top: 1px solid var(--gray-soft-dark); + } + + &-list { + .status { + padding: 1.6rem 2.4rem; + display: flex; + justify-content: space-between; + background-color: var(--gray-soft); + border-radius: var(--border-radius); + p { margin: 0; } + .count { + p { + display: flex; + align-items: center; + @include text-sm; + span { + margin-left: 1.6rem; + @include text-lg(600); + } + } + } + } + .features { + margin: 2.4rem 0 1.6rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1.6rem 2.4rem; + &-item { + padding-left: 1.6rem; + display: flex; + align-items: center; + background-color: var(--information-light); + border-radius: 100px; + color: var(--information-dark); + p { margin: 0; } + button { + width: 3.2rem; + height: 3.2rem; + background: none; + border: none; + } + } + } + + &-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0.8rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + &:not(:first-child) { + margin-top: 1.6rem; + } + + .itemInfo { + display: flex; + justify-content: flex-end; + align-items: center; + + &-thumbnail { + width: 4.8rem; + height: 4.8rem; + margin-right: 1.6rem; + object-fit: cover; + border-radius: var(--border-radius); + background-color: var(--gray-soft); + } + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + } + + .actions { + display: flex; + justify-content: flex-end; + align-items: center; + .manage { + overflow: hidden; + display: flex; + border-radius: var(--border-radius-sm); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-btn {background: var(--gray-hard-darkest) } + .delete-btn {background: var(--error) } + } + } + } + } } .new-product, .edit-product { max-width: 1300px; + padding-right: 1.6rem; + padding-left: 1.6rem; .product-form { - .grp { + .flex { display: flex; - gap: 2.4rem 3.2rem; - .span-7 { - min-width: 70%; + flex-wrap: wrap; + align-items: flex-end; + gap: 0 3.2rem; + & > * { + flex: 1 1 320px; + } + } + + .layout { + @media (max-width: 1023px) { + .span-3, + .span-7 { + flex-basis: 50%; + } + } + @media (max-width: 767px) { + flex-wrap: wrap; + } + } + + .price-data { + .layout { + align-items: center; } } } diff --git a/app/frontend/templates/admin/store/index.html b/app/frontend/templates/admin/store/index.html index 0b7557e96..745b51c57 100644 --- a/app/frontend/templates/admin/store/index.html +++ b/app/frontend/templates/admin/store/index.html @@ -17,7 +17,7 @@
-
+
diff --git a/app/frontend/templates/admin/store/product_edit.html b/app/frontend/templates/admin/store/product_edit.html index f098a8b92..6c2f86d18 100644 --- a/app/frontend/templates/admin/store/product_edit.html +++ b/app/frontend/templates/admin/store/product_edit.html @@ -27,7 +27,7 @@
-
+
diff --git a/app/frontend/templates/admin/store/product_new.html b/app/frontend/templates/admin/store/product_new.html index db9dae230..8c4fb9a05 100644 --- a/app/frontend/templates/admin/store/product_new.html +++ b/app/frontend/templates/admin/store/product_new.html @@ -27,7 +27,7 @@
-
+
From ecb7f6d640be1993a130cc4a01a785093dd11a70 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 1 Aug 2022 16:17:21 +0200 Subject: [PATCH 066/361] (wip) drag and drop --- .../categories/product-categories-item.tsx | 67 ++-- .../categories/product-categories-tree.tsx | 294 ++++++++++++------ .../stylesheets/modules/store/dropOptions.md | 35 +++ .../modules/store/product-categories.scss | 98 ++++-- package.json | 1 + yarn.lock | 8 + 6 files changed, 356 insertions(+), 147 deletions(-) create mode 100644 app/frontend/src/stylesheets/modules/store/dropOptions.md diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx index ac0bed4cb..5f1143bd4 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -1,14 +1,19 @@ +// TODO: Remove next eslint-disable +/* eslint-disable @typescript-eslint/no-unused-vars */ import React from 'react'; import { ProductCategory } from '../../../models/product-category'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { ManageProductCategory } from './manage-product-category'; -import { DotsSixVertical } from 'phosphor-react'; +import { CaretDown, DotsSixVertical } from 'phosphor-react'; interface ProductCategoriesItemProps { productCategories: Array, category: ProductCategory, - isChild?: boolean, + offset: boolean, + collapsed?: boolean, + handleCollapse?: (id: number) => void, + status: 'child' | 'single' | 'parent', onSuccess: (message: string) => void, onError: (message: string) => void, } @@ -16,41 +21,53 @@ interface ProductCategoriesItemProps { /** * Renders a draggable category item */ -export const ProductCategoriesItem: React.FC = ({ productCategories, category, isChild, onSuccess, onError }) => { +export const ProductCategoriesItem: React.FC = ({ productCategories, category, offset, collapsed, handleCollapse, status, onSuccess, onError }) => { const { attributes, listeners, setNodeRef, transform, - transition + transition, + isDragging } = useSortable({ id: category.id }); const style = { - transform: CSS.Transform.toString(transform), - transition + transition, + transform: CSS.Transform.toString(transform) }; return ( -
-
-

{category.name}

- [count] -
-
-
- - +
+ {(status === 'child' || offset) && +
+ } +
+
+ {status === 'parent' &&
+ +
} +

{category.name}

+ [count]
-
- +
+
+ + +
+
+ +
diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx index 01b8797af..6c1bc0783 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx @@ -1,9 +1,11 @@ +// TODO: Remove next eslint-disable /* eslint-disable @typescript-eslint/no-unused-vars */ import React, { useEffect, useState } from 'react'; import { useImmer } from 'use-immer'; import { ProductCategory } from '../../../models/product-category'; import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, DragMoveEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { restrictToWindowEdges } from '@dnd-kit/modifiers'; import { ProductCategoriesItem } from './product-categories-item'; interface ProductCategoriesTreeProps { @@ -18,13 +20,18 @@ interface ProductCategoriesTreeProps { */ export const ProductCategoriesTree: React.FC = ({ productCategories, onDnd, onSuccess, onError }) => { const [categoriesList, setCategoriesList] = useImmer(productCategories); - const [hiddenChildren, setHiddenChildren] = useState({}); + const [activeData, setActiveData] = useImmer(initActiveData); + // TODO: type extractedChildren: {[parentId]: ProductCategory[]} ??? + const [extractedChildren, setExtractedChildren] = useImmer({}); + const [collapsed, setCollapsed] = useImmer([]); + const [offset, setOffset] = useState(false); - // Initialize state from props, sorting list as a tree + // Initialize state from props useEffect(() => { setCategoriesList(productCategories); }, [productCategories]); + // Dnd Kit config const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { @@ -34,145 +41,238 @@ export const ProductCategoriesTree: React.FC = ({ pr /** * On drag start + * Collect dragged items' data + * Extract children from list */ const handleDragStart = ({ active }: DragMoveEvent) => { - hideChildren(active.id, categoriesList.findIndex(el => el.id === active.id)); - const activeChildren = categoriesList.filter(c => c.parent_id === active.id); - if (activeChildren.length) { - setHiddenChildren({ [active.id]: activeChildren }); - const activeIndex = categoriesList.findIndex(el => el.id === active.id); - const tmpList = [...categoriesList]; - tmpList.splice(activeIndex + 1, activeChildren.length); - setCategoriesList(tmpList); - } + const activeIndex = active.data.current.sortable.index; + const children = getChildren(active.id); + + setActiveData(draft => { + draft.index = activeIndex; + draft.category = getCategory(active.id); + draft.status = getStatus(active.id); + draft.children = children?.length ? children : null; + }); + + setExtractedChildren(draft => { draft[active.id] = children; }); + hideChildren(active.id, activeIndex); }; /** * On drag move */ - const handleDragMove = ({ delta, over }: DragMoveEvent) => { - console.log(findCategory(over.id).name); - if (delta.x > 48) { - console.log('Child'); - } else { - console.log('Parent'); + const handleDragMove = ({ delta, active, over }: DragMoveEvent) => { + if ((getStatus(active.id) === 'single' || getStatus(active.id) === 'child') && getStatus(over.id) === 'single') { + if (delta.x > 32) { + setOffset(true); + } else { + setOffset(false); + } } }; /** - * Update categories list after an item was dropped + * On drag End + * Insert children back in list */ - const handleDragEnd = ({ active, over }: DragMoveEvent) => { let newOrder = [...categoriesList]; + const currentIdsOrder = over?.data.current.sortable.items; + let newIndex = over.data.current.sortable.index; - // si déplacé sur une autre catégorie… - if (active.id !== over.id) { - // liste d'ids des catégories visibles - const previousIdsOrder = over?.data.current.sortable.items; - // index dans previousIdsOrder de la catégorie déplacée - const oldIndex = active.data.current.sortable.index; - // index dans previousIdsOrder de la catégorie de réception - const newIndex = over.data.current.sortable.index; - // liste de catégories mise à jour après le drop - const newIdsOrder = arrayMove(previousIdsOrder, oldIndex, newIndex); - // id du parent de la catégorie de réception - const newParentId = categoriesList[newIndex].parent_id; - - // nouvelle liste de catégories classées par newIdsOrder + // [A] Single |> [B] Single + if (getStatus(active.id) === 'single' && getStatus(over.id) === 'single') { + console.log('[A] Single |> [B] Single'); + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); newOrder = newIdsOrder.map(sortedId => { - // catégorie courante du map retrouvée grâce à l'id - const categoryFromId = findCategory(sortedId); - // si catégorie courante = catégorie déplacée… - if (categoryFromId.id === active.id) { - // maj du parent - categoryFromId.parent_id = newParentId; + let category = getCategory(sortedId); + if (offset && sortedId === active.id && activeData.index < newIndex) { + category = { ...category, parent_id: Number(over.id) }; } - // retour de la catégorie courante - return categoryFromId; + return category; }); } - // insert siblings back - if (hiddenChildren[active.id]?.length) { - newOrder.splice(over.data.current.sortable.index + 1, 0, ...hiddenChildren[active.id]); - setHiddenChildren({ ...hiddenChildren, [active.id]: null }); + + // [A] Child |> [B] Single + if ((getStatus(active.id) === 'child') && getStatus(over.id) === 'single') { + console.log('[A] Child |> [B] Single'); + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (offset && sortedId === active.id && activeData.index < newIndex) { + category = { ...category, parent_id: Number(over.id) }; + } else if (sortedId === active.id && activeData.index < newIndex) { + category = { ...category, parent_id: null }; + } + return category; + }); } + + // [A] Single || Child |>… + if (getStatus(active.id) === 'single' || getStatus(active.id) === 'child') { + // [B] Parent + if (getStatus(over.id) === 'parent') { + if (activeData.index < newIndex) { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: Number(over.id) }; + } + return category; + }); + } else { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: null }; + } + return category; + }); + } + } + // [B] Child + if (getStatus(over.id) === 'child') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: getCategory(over.id).parent_id }; + } + return category; + }); + } + } + + // [A] Parent |>… + if (getStatus(active.id) === 'parent') { + // [B] Single + if (getStatus(over.id) === 'single') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + // [B] Parent + if (getStatus(over.id) === 'parent') { + if (activeData.index < newIndex) { + const lastOverChildIndex = newOrder.findIndex(c => c.id === getChildren(over.id).pop().id); + newIndex = lastOverChildIndex; + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } else { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + } + // [B] Child + if (getStatus(over.id) === 'child') { + if (activeData.index < newIndex) { + const parent = newOrder.find(c => c.id === getCategory(over.id).parent_id); + const lastSiblingIndex = newOrder.findIndex(c => c.id === getChildren(parent.id).pop().id); + newIndex = lastSiblingIndex; + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } else { + const parentIndex = currentIdsOrder.indexOf(getCategory(over.id).parent_id); + newIndex = parentIndex; + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + } + // insert children back + newOrder = showChildren(active.id, newOrder, newIndex); + } + onDnd(newOrder); + setOffset(false); }; /** - * Reset state if the drag was canceled + * On drag cancel + * Reset states */ const handleDragCancel = ({ active }: DragMoveEvent) => { - setHiddenChildren({ ...hiddenChildren, [active.id]: null }); setCategoriesList(productCategories); + setActiveData(initActiveData); + setExtractedChildren({ ...extractedChildren, [active.id]: null }); }; /** - * Hide children by their parent's id + * Get a category by its id */ - const hideChildren = (parentId, parentIndex) => { - const children = findChildren(parentId); - if (children?.length) { - const tmpList = [...categoriesList]; - tmpList.splice(parentIndex + 1, children.length); - setCategoriesList(tmpList); - } - }; - - /** - * Find a category by its id - */ - const findCategory = (id) => { + const getCategory = (id) => { return categoriesList.find(c => c.id === id); }; + /** - * Find the children categories of a parent category by its id + * Get the children categories of a parent category by its id */ - const findChildren = (id) => { + const getChildren = (id) => { const displayedChildren = categoriesList.filter(c => c.parent_id === id); if (displayedChildren.length) { return displayedChildren; } - return hiddenChildren[id]; + return extractedChildren[id]; }; + /** - * Find category's status by its id - * single | parent | child + * Get category's status by its id + * child | single | parent */ - const categoryStatus = (id) => { - const c = findCategory(id); - if (!c.parent_id) { - if (findChildren(id)?.length) { - return 'parent'; - } - return 'single'; - } else { - return 'child'; + const getStatus = (id) => { + const c = getCategory(id); + return !c.parent_id + ? getChildren(id)?.length + ? 'parent' + : 'single' + : 'child'; + }; + + /** + * Extract children from the list by their parent's id + */ + const hideChildren = (parentId, parentIndex) => { + const children = getChildren(parentId); + if (children?.length) { + const shortenList = [...categoriesList]; + shortenList.splice(parentIndex + 1, children.length); + setCategoriesList(shortenList); } }; /** - * Translate visual order into categories data positions + * Insert children back in the list by their parent's id */ - const indexToPosition = (sortedIds: number[]) => { - const sort = sortedIds.map(sortedId => categoriesList.find(el => el.id === sortedId)); - const newPositions = sort.map(c => { - if (typeof c.parent_id === 'number') { - const parentIndex = sort.findIndex(el => el.id === c.parent_id); - const currentIndex = sort.findIndex(el => el.id === c.id); - return { ...c, position: (currentIndex - parentIndex - 1) }; - } - return c; - }); - return newPositions; + const showChildren = (parentId, currentList, insertIndex) => { + if (extractedChildren[parentId]?.length) { + currentList.splice(insertIndex + 1, 0, ...extractedChildren[parentId]); + setExtractedChildren({ ...extractedChildren, [parentId]: null }); + } + return currentList; + }; + + /** + * Toggle parent category by hidding/showing its children + */ + const handleCollapse = (id) => { + const i = collapsed.findIndex(el => el === id); + if (i === -1) { + setCollapsed([...collapsed, id]); + } else { + const copy = [...collapsed]; + copy.splice(i, 1); + setCollapsed(copy); + } }; return ( @@ -185,7 +285,10 @@ export const ProductCategoriesTree: React.FC = ({ pr category={category} onSuccess={onSuccess} onError={onError} - isChild={typeof category.parent_id === 'number'} + offset={category.id === activeData.category?.id && activeData?.offset} + collapsed={collapsed.includes(category.id) || collapsed.includes(category.parent_id)} + handleCollapse={handleCollapse} + status={getStatus(category.id)} /> ))}
@@ -193,3 +296,18 @@ export const ProductCategoriesTree: React.FC = ({ pr ); }; + +interface ActiveData { + index: number, + category: ProductCategory, + status: 'child' | 'single' | 'parent', + children: ProductCategory[], + offset: boolean +} +const initActiveData: ActiveData = { + index: null, + category: null, + status: null, + children: [], + offset: false +}; diff --git a/app/frontend/src/stylesheets/modules/store/dropOptions.md b/app/frontend/src/stylesheets/modules/store/dropOptions.md new file mode 100644 index 000000000..d8ff63b24 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/dropOptions.md @@ -0,0 +1,35 @@ + + +## [A] Single |> [B] Single + [A] = index de [B] + offset && [A] child de [B] + + + + + +## [A] Child |> [B] Single + [A] = index de [B] + offset + ? [A] child de [B] + : [A] Single + + + + + + + +## [A] Single |> [A] + offset && [A] child du précédant parent \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 7e2f2b0dc..1f3585856 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -41,50 +41,80 @@ } &-item { display: flex; - justify-content: space-between; - align-items: center; - padding: 0.6rem 1.6rem; - border: 1px solid var(--gray-soft-dark); - border-radius: var(--border-radius); + pointer-events: all; - .itemInfo { - display: flex; - justify-content: flex-end; - align-items: center; - &-name { - margin: 0; - @include text-base; - font-weight: 600; - color: var(--gray-hard-darkest); - } - &-count { - margin-left: 2.4rem; - @include text-sm; - font-weight: 500; - color: var(--information); - } + &.is-collapsed { + height: 0; + margin: 0; + padding: 0; + border: none; + overflow: hidden; + pointer-events: none; + } + .offset { + width: 4.8rem; } - .actions { + .wrap { + width: 100%; display: flex; - justify-content: flex-end; + justify-content: space-between; align-items: center; - .manage { - overflow: hidden; + padding: 0.6rem 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + .itemInfo { display: flex; - border-radius: var(--border-radius-sm); - button { - @include btn; - border-radius: 0; - color: var(--gray-soft-lightest); - &:hover { opacity: 0.75; } + align-items: center; + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + &-count { + margin-left: 2.4rem; + @include text-sm; + font-weight: 500; + color: var(--information); + } + } + + .actions { + display: flex; + justify-content: flex-end; + align-items: center; + .manage { + overflow: hidden; + display: flex; + border-radius: var(--border-radius-sm); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-btn { background: var(--gray-hard-darkest); } + .delete-btn { background: var(--error); } } - .edit-btn {background: var(--gray-hard-darkest) } - .delete-btn {background: var(--error) } } } - .draghandle { + .collapse-handle { + width: 4rem; + margin: 0 0 0 -1rem; + button { + @include btn; + background: none; + border-radius: 0; + transition: transform 250ms ease-in-out; + &.rotate { + transform: rotateZ(-180deg); + } + } + } + .drag-handle button { @include btn; cursor: grab; } diff --git a/package.json b/package.json index ff8113ae1..852452ee8 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@babel/runtime": "^7.17.2", "@claviska/jquery-minicolors": "^2.3.5", "@dnd-kit/core": "^6.0.5", + "@dnd-kit/modifiers": "^6.0.0", "@dnd-kit/sortable": "^7.0.1", "@fortawesome/fontawesome-free": "5.14.0", "@lyracom/embedded-form-glue": "^0.3.3", diff --git a/yarn.lock b/yarn.lock index 968221b91..b82199d18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1504,6 +1504,14 @@ "@dnd-kit/utilities" "^3.2.0" tslib "^2.0.0" +"@dnd-kit/modifiers@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/modifiers/-/modifiers-6.0.0.tgz#61d8834132f791a68e9e93be5426becbcd45c078" + integrity sha512-V3+JSo6/BTcgPRHiNUTSKgqVv/doKXg+T4Z0QvKiiXp+uIyJTUtPkQOBRQApUWi3ApBhnoWljyt/3xxY4fTd0Q== + dependencies: + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + "@dnd-kit/sortable@^7.0.1": version "7.0.1" resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-7.0.1.tgz#99c6012bbab4d8bb726c0eef7b921a338c404fdb" From ea1171ba0f825fe5d3fb1d50154074ac2b8e1d43 Mon Sep 17 00:00:00 2001 From: vincent Date: Wed, 3 Aug 2022 09:59:52 +0200 Subject: [PATCH 067/361] Remove test text --- .../components/store/categories/product-categories.tsx | 2 +- app/frontend/templates/admin/calendar/icalendar.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx index 449a965fd..25a7dc617 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -85,7 +85,7 @@ const ProductCategories: React.FC = ({ onSuccess, onErro - Plop + Save
diff --git a/app/frontend/templates/admin/calendar/icalendar.html b/app/frontend/templates/admin/calendar/icalendar.html index ab91191d3..edd60ecfe 100644 --- a/app/frontend/templates/admin/calendar/icalendar.html +++ b/app/frontend/templates/admin/calendar/icalendar.html @@ -38,7 +38,7 @@ - {{calendar.name}} plop + {{calendar.name}} {{calendar.url}} {{ calendar.text_hidden ? '' : 'app.admin.icalendar.example' }} From 0773e5bc821518d9d05a587ccc9e900419992784 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Tue, 2 Aug 2022 19:47:56 +0200 Subject: [PATCH 068/361] product files and images upload --- app/controllers/api/products_controller.rb | 23 +++- app/frontend/src/javascript/api/product.ts | 63 ++++++++- .../components/form/form-file-upload.tsx | 129 ++++++++++++++++++ .../components/form/form-image-upload.tsx | 119 ++++++++++++++++ .../components/store/new-product.tsx | 4 +- .../components/store/product-form.tsx | 85 +++++++++++- app/frontend/src/javascript/models/product.ts | 16 +++ app/frontend/src/stylesheets/application.scss | 3 + .../modules/form/form-file-upload.scss | 106 ++++++++++++++ .../modules/form/form-image-upload.scss | 48 +++++++ .../modules/store/product-form.scss | 4 + app/models/product.rb | 6 + app/models/product_file.rb | 6 + app/models/product_image.rb | 6 + app/uploaders/product_file_uploader.rb | 66 +++++++++ app/uploaders/product_image_uploader.rb | 76 +++++++++++ app/views/api/products/_product.json.jbuilder | 10 ++ config/locales/app.admin.en.yml | 6 + config/locales/app.admin.fr.yml | 6 + config/locales/app.shared.en.yml | 6 + config/locales/app.shared.fr.yml | 6 + 21 files changed, 785 insertions(+), 9 deletions(-) create mode 100644 app/frontend/src/javascript/components/form/form-file-upload.tsx create mode 100644 app/frontend/src/javascript/components/form/form-image-upload.tsx create mode 100644 app/frontend/src/stylesheets/modules/form/form-file-upload.scss create mode 100644 app/frontend/src/stylesheets/modules/form/form-image-upload.scss create mode 100644 app/frontend/src/stylesheets/modules/store/product-form.scss create mode 100644 app/models/product_file.rb create mode 100644 app/models/product_image.rb create mode 100644 app/uploaders/product_file_uploader.rb create mode 100644 app/uploaders/product_image_uploader.rb diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index e411ce090..b21e65be1 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -15,8 +15,13 @@ class API::ProductsController < API::ApiController def create authorize Product @product = Product.new(product_params) - @product.amount = nil if @product.amount.zero? - @product.amount *= 100 if @product.amount.present? + if @product.amount.present? + if @product.amount.zero? + @product.amount = nil + else + @product.amount *= 100 + end + end if @product.save render status: :created else @@ -28,8 +33,13 @@ class API::ProductsController < API::ApiController authorize @product product_parameters = product_params - product_parameters[:amount] = nil if product_parameters[:amount].zero? - product_parameters[:amount] = product_parameters[:amount] * 100 if product_parameters[:amount].present? + if product_parameters[:amount].present? + if product_parameters[:amount].zero? + product_parameters[:amount] = nil + else + product_parameters[:amount] *= 100 + end + end if @product.update(product_parameters) render status: :ok else @@ -52,6 +62,9 @@ class API::ProductsController < API::ApiController def product_params params.require(:product).permit(:name, :slug, :sku, :description, :is_active, :product_category_id, :amount, :quantity_min, - :low_stock_alert, :low_stock_threshold, machine_ids: []) + :low_stock_alert, :low_stock_threshold, + machine_ids: [], + product_files_attributes: %i[id attachment _destroy], + product_images_attributes: %i[id attachment _destroy]) end end diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts index edb434c95..c89a32642 100644 --- a/app/frontend/src/javascript/api/product.ts +++ b/app/frontend/src/javascript/api/product.ts @@ -1,5 +1,6 @@ import apiClient from './clients/api-client'; import { AxiosResponse } from 'axios'; +import { serialize } from 'object-to-formdata'; import { Product } from '../models/product'; export default class ProductAPI { @@ -14,12 +15,70 @@ export default class ProductAPI { } static async create (product: Product): Promise { - const res: AxiosResponse = await apiClient.post('/api/products', { product }); + const data = serialize({ + product: { + ...product, + product_files_attributes: null, + product_images_attributes: null + } + }); + data.delete('product[product_files_attributes]'); + data.delete('product[product_images_attributes]'); + product.product_files_attributes?.forEach((file, i) => { + if (file?.attachment_files && file?.attachment_files[0]) { + data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]); + } + }); + product.product_images_attributes?.forEach((image, i) => { + if (image?.attachment_files && image?.attachment_files[0]) { + data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]); + } + }); + const res: AxiosResponse = await apiClient.post('/api/products', data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); return res?.data; } static async update (product: Product): Promise { - const res: AxiosResponse = await apiClient.patch(`/api/products/${product.id}`, { product }); + const data = serialize({ + product: { + ...product, + product_files_attributes: null, + product_images_attributes: null + } + }); + data.delete('product[product_files_attributes]'); + data.delete('product[product_images_attributes]'); + product.product_files_attributes?.forEach((file, i) => { + if (file?.attachment_files && file?.attachment_files[0]) { + data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]); + } + if (file?.id) { + data.set(`product[product_files_attributes][${i}][id]`, file.id.toString()); + } + if (file?._destroy) { + data.set(`product[product_files_attributes][${i}][_destroy]`, file._destroy.toString()); + } + }); + product.product_images_attributes?.forEach((image, i) => { + if (image?.attachment_files && image?.attachment_files[0]) { + data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]); + } + if (image?.id) { + data.set(`product[product_images_attributes][${i}][id]`, image.id.toString()); + } + if (image?._destroy) { + data.set(`product[product_images_attributes][${i}][_destroy]`, image._destroy.toString()); + } + }); + const res: AxiosResponse = await apiClient.patch(`/api/products/${product.id}`, data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); return res?.data; } diff --git a/app/frontend/src/javascript/components/form/form-file-upload.tsx b/app/frontend/src/javascript/components/form/form-file-upload.tsx new file mode 100644 index 000000000..b545ebbbb --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-file-upload.tsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Path } from 'react-hook-form'; +import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { FieldPathValue } from 'react-hook-form/dist/types/path'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FormInput } from '../form/form-input'; +import { FormComponent } from '../../models/form-component'; +import { AbstractFormItemProps } from './abstract-form-item'; + +export interface FileType { + id?: number, + attachment_name?: string, + attachment_url?: string +} + +interface FormFileUploadProps extends FormComponent, AbstractFormItemProps { + setValue: UseFormSetValue, + defaultFile?: FileType, + accept?: string, + onFileChange?: (value: FileType) => void, + onFileRemove?: () => void, +} + +/** + * This component allows to upload file, in forms managed by react-hook-form. + */ +export const FormFileUpload = ({ id, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps) => { + const { t } = useTranslation('shared'); + + const [file, setFile] = useState(defaultFile); + + /** + * Check if file is selected + */ + const hasFile = (): boolean => { + return !!file?.attachment_name; + }; + + /** + * Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled). + */ + function onFileSelected (event: React.ChangeEvent) { + const f = event.target?.files[0]; + if (f) { + setFile({ + attachment_name: f.name + }); + setValue( + `${id}[_destroy]` as Path, + false as UnpackNestedValue>> + ); + if (typeof onFileChange === 'function') { + onFileChange({ attachment_name: f.name }); + } + } + } + + /** + * Callback triggered when the user clicks on the delete button. + */ + function onRemoveFile () { + if (file?.id) { + setValue( + `${id}[_destroy]` as Path, + true as UnpackNestedValue>> + ); + } + setValue( + `${id}[attachment_files]` as Path, + null as UnpackNestedValue>> + ); + setFile(null); + if (typeof onFileRemove === 'function') { + onFileRemove(); + } + } + + // Compose classnames from props + const classNames = [ + `${className || ''}` + ].join(' '); + + return ( +
+
+ {hasFile() && ( +
+ + + {file.attachment_name} + +
+ )} + {file?.id && file?.attachment_url && ( + + + + )} +
+ + {!hasFile() && ( + {t('app.shared.form_file_upload.browse')} + )} + {hasFile() && ( + {t('app.shared.form_file_upload.edit')} + )} + + + {hasFile() && ( + + + + )} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/form/form-image-upload.tsx b/app/frontend/src/javascript/components/form/form-image-upload.tsx new file mode 100644 index 000000000..76bde2632 --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-image-upload.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Path } from 'react-hook-form'; +import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { FieldPathValue } from 'react-hook-form/dist/types/path'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FormInput } from '../form/form-input'; +import { FormComponent } from '../../models/form-component'; +import { AbstractFormItemProps } from './abstract-form-item'; +import { FabButton } from '../base/fab-button'; +import noAvatar from '../../../../images/no_avatar.png'; + +export interface ImageType { + id?: number, + attachment_name?: string, + attachment_url?: string +} + +interface FormImageUploadProps extends FormComponent, AbstractFormItemProps { + setValue: UseFormSetValue, + defaultImage?: ImageType, + accept?: string, + size?: 'small' | 'large' + onFileChange?: (value: ImageType) => void, + onFileRemove?: () => void, +} + +/** + * This component allows to upload image, in forms managed by react-hook-form. + */ +export const FormImageUpload = ({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size }: FormImageUploadProps) => { + const { t } = useTranslation('shared'); + + const [file, setFile] = useState(defaultImage); + const [image, setImage] = useState(defaultImage.attachment_url); + + /** + * Check if image is selected + */ + const hasImage = (): boolean => { + return !!file?.attachment_name; + }; + + /** + * Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled). + */ + function onFileSelected (event: React.ChangeEvent) { + const f = event.target?.files[0]; + if (f) { + const reader = new FileReader(); + reader.onload = (): void => { + setImage(reader.result); + }; + reader.readAsDataURL(f); + setFile({ + attachment_name: f.name + }); + setValue( + `${id}[_destroy]` as Path, + false as UnpackNestedValue>> + ); + if (typeof onFileChange === 'function') { + onFileChange({ attachment_name: f.name }); + } + } + } + + /** + * Callback triggered when the user clicks on the delete button. + */ + function onRemoveFile () { + if (file?.id) { + setValue( + `${id}[_destroy]` as Path, + true as UnpackNestedValue>> + ); + } + setValue( + `${id}[attachment_files]` as Path, + null as UnpackNestedValue>> + ); + setFile(null); + setImage(null); + if (typeof onFileRemove === 'function') { + onFileRemove(); + } + } + + // Compose classnames from props + const classNames = [ + `${className || ''}` + ].join(' '); + + return ( +
+
+ +
+
+ + {!hasImage() && {t('app.shared.form_image_upload.browse')}} + {hasImage() && {t('app.shared.form_image_upload.edit')}} + + + {hasImage() && } className="delete-image" />} +
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/new-product.tsx b/app/frontend/src/javascript/components/store/new-product.tsx index 44c336ffa..5747e50ed 100644 --- a/app/frontend/src/javascript/components/store/new-product.tsx +++ b/app/frontend/src/javascript/components/store/new-product.tsx @@ -31,7 +31,9 @@ const NewProduct: React.FC = ({ onSuccess, onError }) => { external: 0 }, low_stock_alert: false, - machine_ids: [] + machine_ids: [], + product_files_attributes: [], + product_images_attributes: [] }; /** diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index e00f38f79..e2139568f 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import slugify from 'slugify'; import _ from 'lodash'; @@ -10,6 +10,8 @@ import { FormSwitch } from '../form/form-switch'; import { FormSelect } from '../form/form-select'; import { FormChecklist } from '../form/form-checklist'; import { FormRichText } from '../form/form-rich-text'; +import { FormFileUpload } from '../form/form-file-upload'; +import { FormImageUpload } from '../form/form-image-upload'; import { FabButton } from '../base/fab-button'; import { FabAlert } from '../base/fab-alert'; import ProductCategoryAPI from '../../api/product-category'; @@ -41,6 +43,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc const { t } = useTranslation('admin'); const { handleSubmit, register, control, formState, setValue, reset } = useForm({ defaultValues: { ...product } }); + const output = useWatch({ control }); const [isActivePrice, setIsActivePrice] = useState(product.id && _.isFinite(product.amount) && product.amount > 0); const [productCategories, setProductCategories] = useState([]); const [machines, setMachines] = useState([]); @@ -117,6 +120,46 @@ export const ProductForm: React.FC = ({ product, title, onSucc } }; + /** + * Add new product file + */ + const addProductFile = () => { + setValue('product_files_attributes', output.product_files_attributes.concat({})); + }; + + /** + * Remove a product file + */ + const handleRemoveProductFile = (i: number) => { + return () => { + const productFile = output.product_files_attributes[i]; + if (!productFile.id) { + output.product_files_attributes.splice(i, 1); + setValue('product_files_attributes', output.product_files_attributes); + } + }; + }; + + /** + * Add new product image + */ + const addProductImage = () => { + setValue('product_images_attributes', output.product_images_attributes.concat({})); + }; + + /** + * Remove a product image + */ + const handleRemoveProductImage = (i: number) => { + return () => { + const productImage = output.product_images_attributes[i]; + if (!productImage.id) { + output.product_images_attributes.splice(i, 1); + setValue('product_images_attributes', output.product_images_attributes); + } + }; + }; + return ( <>
@@ -187,6 +230,28 @@ export const ProductForm: React.FC = ({ product, title, onSucc
+
+

{t('app.admin.store.product_form.product_images')}

+ + + +
+ {output.product_images_attributes.map((image, i) => ( + + ))} +
+ {t('app.admin.store.product_form.add_product_image')} +

{t('app.admin.store.product_form.assigning_category')}

@@ -218,6 +283,24 @@ export const ProductForm: React.FC = ({ product, title, onSucc paragraphTools={true} limit={1000} id="description" /> +
+

{t('app.admin.store.product_form.product_files')}

+ + + + {output.product_files_attributes.map((file, i) => ( + + ))} + {t('app.admin.store.product_form.add_product_file')} +
{t('app.admin.store.product_form.save')} diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index 9038dbd5c..471e00248 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -22,4 +22,20 @@ export interface Product { low_stock_alert: boolean, low_stock_threshold?: number, machine_ids: number[], + product_files_attributes: Array<{ + id?: number, + attachment?: File, + attachment_files?: FileList, + attachment_name?: string, + attachment_url?: string + _destroy?: boolean + }>, + product_images_attributes: Array<{ + id?: number, + attachment?: File, + attachment_files?: FileList, + attachment_name?: string, + attachment_url?: string + _destroy?: boolean + }> } diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index a461b6ae3..704204551 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -39,6 +39,8 @@ @import "modules/form/form-rich-text"; @import "modules/form/form-switch"; @import "modules/form/form-checklist"; +@import "modules/form/form-file-upload"; +@import "modules/form/form-image-upload"; @import "modules/group/change-group"; @import "modules/machines/machine-card"; @import "modules/machines/machines-filters"; @@ -102,6 +104,7 @@ @import "modules/user/gender-input"; @import "modules/user/user-profile-form"; @import "modules/user/user-validation"; +@import "modules/store/product-form"; @import "modules/abuses"; @import "modules/cookies"; diff --git a/app/frontend/src/stylesheets/modules/form/form-file-upload.scss b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss new file mode 100644 index 000000000..284bc29c4 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss @@ -0,0 +1,106 @@ +.fileinput { + display: table; + border-collapse: separate; + position: relative; + margin-bottom: 9px; + + .filename-container { + align-items: center; + display: inline-flex; + float: left; + margin-bottom: 0; + position: relative; + width: 100%; + z-index: 2; + background-color: #fff; + background-image: none; + border: 1px solid #c4c4c4; + border-radius: 4px; + box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%); + height: 38px; + padding: 6px 12px; + transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; + color: #555; + font-size: 16px; + line-height: 1.5; + + .fileinput-filename { + vertical-align: bottom; + display: inline-block; + overflow: hidden; + margin-left: 10px; + } + + .file-download { + position: absolute; + right: 10px; + + i { + color: black; + } + } + } + .fileinput-button { + z-index: 1; + border: 1px solid #c4c4c4; + border-left: 0; + border-radius: 0 4px 4px 0; + position: relative; + vertical-align: middle; + background-color: #eee; + color: #555; + font-size: 16px; + font-weight: 400; + line-height: 1; + padding: 6px 12px; + text-align: center; + white-space: nowrap; + width: 1%; + display: table-cell; + background-image: none; + touch-action: manipulation; + overflow: hidden; + cursor: pointer; + border-collapse: separate; + border-spacing: 0; + + .form-input { + position: absolute; + z-index: 2; + opacity: 0; + top: 0; + left: 0; + } + + input[type=file] { + display: block; + cursor: pointer; + direction: ltr; + filter: alpha(opacity=0); + font-size: 23px; + height: 100%; + margin: 0; + opacity: 0; + position: absolute; + right: 0; + top: 0; + width: 100%; + } + } + + .fileinput-delete { + padding: 6px 12px; + font-size: 16px; + font-weight: 400; + line-height: 1; + color: #555555; + text-align: center; + background-color: #eeeeee; + border: 1px solid #c4c4c4; + border-radius: 4px; + width: 1%; + white-space: nowrap; + vertical-align: middle; + display: table-cell; + } +} diff --git a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss new file mode 100644 index 000000000..fa35d9a26 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss @@ -0,0 +1,48 @@ +.form-image-upload { + + .image { + background-color: #fff; + border: 1px solid var(--gray-soft); + padding: 4px; + display: inline-block; + + &--small img { + width: 50px; + height: 50px; + } + + &--large img { + width: 180px; + height: 180px; + } + } + + .buttons { + display: flex; + justify-content: center; + margin-top: 20px; + + .select-button { + position: relative; + .image-file-input { + position: absolute; + z-index: 2; + opacity: 0; + top: 0; + left: 0; + } + } + .delete-image { + background-color: var(--error); + color: white; + } + } + + &--large { + margin: 80px 40px; + } + + &--small { + text-align: center; + } +} diff --git a/app/frontend/src/stylesheets/modules/store/product-form.scss b/app/frontend/src/stylesheets/modules/store/product-form.scss new file mode 100644 index 000000000..7c45aa8a7 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-form.scss @@ -0,0 +1,4 @@ +.product-images { + display: flex; + flex-wrap: wrap; +} diff --git a/app/models/product.rb b/app/models/product.rb index d4c26abc2..7bc4087a7 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -6,6 +6,12 @@ class Product < ApplicationRecord has_and_belongs_to_many :machines + has_many :product_files, as: :viewable, dependent: :destroy + accepts_nested_attributes_for :product_files, allow_destroy: true, reject_if: :all_blank + + has_many :product_images, as: :viewable, dependent: :destroy + accepts_nested_attributes_for :product_images, allow_destroy: true, reject_if: :all_blank + validates_numericality_of :amount, greater_than: 0, allow_nil: true scope :active, -> { where(is_active: true) } diff --git a/app/models/product_file.rb b/app/models/product_file.rb new file mode 100644 index 000000000..fd23a9b3f --- /dev/null +++ b/app/models/product_file.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# ProductFile is a file stored on the file system, associated with a Product. +class ProductFile < Asset + mount_uploader :attachment, ProductFileUploader +end diff --git a/app/models/product_image.rb b/app/models/product_image.rb new file mode 100644 index 000000000..9a5da4a85 --- /dev/null +++ b/app/models/product_image.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# ProductImage is an image stored on the file system, associated with a Product. +class ProductImage < Asset + mount_uploader :attachment, ProductImageUploader +end diff --git a/app/uploaders/product_file_uploader.rb b/app/uploaders/product_file_uploader.rb new file mode 100644 index 000000000..1cee5d75c --- /dev/null +++ b/app/uploaders/product_file_uploader.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# CarrierWave uploader for file of product +# This file defines the parameters for these uploads. +class ProductFileUploader < CarrierWave::Uploader::Base + # Include RMagick or MiniMagick support: + # include CarrierWave::RMagick + # include CarrierWave::MiniMagick + include UploadHelper + + # Choose what kind of storage to use for this uploader: + storage :file + # storage :fog + + after :remove, :delete_empty_dirs + + # Override the directory where uploaded files will be stored. + # This is a sensible default for uploaders that are meant to be mounted: + def store_dir + "#{base_store_dir}/#{model.id}" + end + + def base_store_dir + "uploads/#{model.class.to_s.underscore}" + end + + # Provide a default URL as a default if there hasn't been a file uploaded: + # def default_url + # # For Rails 3.1+ asset pipeline compatibility: + # # ActionController::Base.helpers.asset_pack_path("fallback/" + [version_name, "default.png"].compact.join('_')) + # + # "/images/fallback/" + [version_name, "default.png"].compact.join('_') + # end + + # Process files as they are uploaded: + # process :scale => [200, 300] + # + # def scale(width, height) + # # do something + # end + + # Create different versions of your uploaded files: + # version :thumb do + # process :resize_to_fit => [50, 50] + # end + + # Add a white list of extensions which are allowed to be uploaded. + # For images you might use something like this: + def extension_whitelist + %w[pdf] + end + + def content_type_whitelist + ['application/pdf'] + end + + # Override the filename of the uploaded files: + # Avoid using model.id or version_name here, see uploader/store.rb for details. + def filename + if original_filename + original_filename.split('.').map do |s| + ActiveSupport::Inflector.transliterate(s).to_s + end.join('.') + end + end +end diff --git a/app/uploaders/product_image_uploader.rb b/app/uploaders/product_image_uploader.rb new file mode 100644 index 000000000..a7830ab81 --- /dev/null +++ b/app/uploaders/product_image_uploader.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# CarrierWave uploader for image of product +# This file defines the parameters for these uploads. +class ProductImageUploader < CarrierWave::Uploader::Base + include CarrierWave::MiniMagick + include UploadHelper + + # Choose what kind of storage to use for this uploader: + storage :file + after :remove, :delete_empty_dirs + + # Override the directory where uploaded files will be stored. + # This is a sensible default for uploaders that are meant to be mounted: + def store_dir + "#{base_store_dir}/#{model.id}" + end + + def base_store_dir + "uploads/#{model.class.to_s.underscore}" + end + + # Provide a default URL as a default if there hasn't been a file uploaded: + # def default_url + # # For Rails 3.1+ asset pipeline compatibility: + # # ActionController::Base.helpers.asset_pack_path("fallback/" + [version_name, "default.png"].compact.join('_')) + # + # "/images/fallback/" + [version_name, "default.png"].compact.join('_') + # end + + # Process files as they are uploaded: + # process :scale => [200, 300] + # + # def scale(width, height) + # # do something + # end + + # Create different versions of your uploaded files: + # version :thumb do + # process :resize_to_fit => [50, 50] + # end + + # Create different versions of your uploaded files: + version :large do + process resize_to_fit: [1000, 700] + end + + version :medium do + process resize_to_fit: [700, 400] + end + + # Add a white list of extensions which are allowed to be uploaded. + # For images you might use something like this: + def extension_whitelist + %w[jpg jpeg gif png] + end + + def content_type_whitelist + [%r{image/}] + end + + # Override the filename of the uploaded files: + # Avoid using model.id or version_name here, see uploader/store.rb for details. + def filename + if original_filename + original_filename.split('.').map do |s| + ActiveSupport::Inflector.transliterate(s).to_s + end.join('.') + end + end + + # return an array like [width, height] + def dimensions + ::MiniMagick::Image.open(file.file)[:dimensions] + end +end diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder index 624f8e45d..9b9a99336 100644 --- a/app/views/api/products/_product.json.jbuilder +++ b/app/views/api/products/_product.json.jbuilder @@ -2,3 +2,13 @@ json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold, :machine_ids json.amount product.amount / 100.0 if product.amount.present? +json.product_files_attributes product.product_files do |f| + json.id f.id + json.attachment_name f.attachment_identifier + json.attachment_url f.attachment_url +end +json.product_images_attributes product.product_images do |f| + json.id f.id + json.attachment_name f.attachment_identifier + json.attachment_url f.attachment_url +end diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 103e8af33..ee7e70f66 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1954,4 +1954,10 @@ en: assigning_machines_info: "Information
You can link one or more machines from your fablab to your product, this product will then be subject to the filters on the catalogue view.
The machines selected below will be linked to the product." product_description: "Product description" product_description_info: "Information
This product description will be present in the product sheet. You have a few editorial styles at your disposal to create the product sheet." + product_files: "Document" + product_files_info: "Information
Add documents related to this product, the uploaded documents will be presented in the product sheet, in a separate block. You can only upload pdf documents." + add_product_file: "Add a document" + product_images: "Images of product" + product_images_info: "Advice
We advise you to use a square format, jpg or png, for jpgs, please use white for the background colour. The main visual will be the visual presented first in the product sheet." + add_product_image: "Add an image" save: "Save" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index fabc04045..2e9847512 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1953,4 +1953,10 @@ fr: assigning_machines_info: "Information
Vous pouvez lier une ou plusieurs machines de votre fablab à votre produit, Ce produit sera alors assujetti aux filtres sur la vue catalogue.
Les machines sélectionnées ci-dessous seront liées au produit." product_description: "Description du produit" product_description_info: "Information
Cette description du produit sera présente dans la fiche du produit. Vous avez à disposition quelques styles rédactionnels pour créer la fiche du produit." + product_files: "Documentation" + product_files_info: "Information
Ajouter des documents liés à ce produit, les document uploadés seront présentés dans la fiche produit, dans un bloc distinct. Vous pouvez uploadé des pdf uniquement." + add_product_file: "Ajouter un document" + product_images: "Visuel(s) du produit" + product_images_info: "Conseils
Nous vous conseillons d'utiliser un format carré, jpg ou png, pour les jpgs, merci de privilégier le blanc pour la couleur de fond. Le visuel principal sera le visuel présenté en premier dans la fiche produit." + add_product_image: "Ajouter un visuel" save: "Enregistrer" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 123885afa..3fc1e941e 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -553,3 +553,9 @@ en: form_checklist: select_all: "Select all" unselect_all: "Unselect all" + form_file_upload: + browse: "Browse" + edit: "Edit" + form_image_upload: + browse: "Browse" + edit: "Edit" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index 0827df858..b72887968 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -552,3 +552,9 @@ fr: create_label: "Ajouter {VALUE}" form_check_list: select_all: "Tout sélectionner" + form_file_upload: + browse: "Parcourir" + edit: "Modifier" + form_image_upload: + browse: "Parcourir" + edit: "Modifier" From 9561e61f5a1b6c1d2324fd0b43ad771419e676f6 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 3 Aug 2022 10:30:30 +0200 Subject: [PATCH 069/361] update locale fr --- config/locales/app.admin.fr.yml | 12 ++++++------ config/locales/app.shared.fr.yml | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 2e9847512..96c21c09c 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1904,9 +1904,9 @@ fr: title: "Les catégories" info: "Information:
Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveaux maximum, vous pouvez les agencer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. Veillez au bon agencement de vos catégories et sauvegarder votre choix." manage_product_category: - create: "Create a product category" - update: "Update the product category" - delete: "Delete the product category" + create: "Créer une catégorie" + update: "Modifier la catégorie" + delete: "Supprimer la catégorie" product_category_modal: new_product_category: "Créer une catégorie" edit_product_category: "Modifier la catégorie" @@ -1921,12 +1921,12 @@ fr: error: "Impossible de modifier la catégorie : " success: "La nouvelle catégorie a bien été mise à jour." delete: - confirm: "Do you really want to delete this product category?" + confirm: "Voulez-vous vraiment supprimer cette catégorie de produits ?" error: "Impossible de supprimer the catégorie : " success: "La catégorie a bien été supprimée" save: "Enregistrer" - required: "This field is required" - slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + required: "Le champ est requise" + slug_pattern: "Uniquement des groupes de caractères alphanumériques minuscules séparés par un trait d'union." products: all_products: "Tous les produits" create_a_product: "Créer un produit" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index b72887968..139728a02 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -550,8 +550,9 @@ fr: validate_button: "Valider la nouvelle carte" form_multi_select: create_label: "Ajouter {VALUE}" - form_check_list: + form_checklist: select_all: "Tout sélectionner" + unselect_all: "Tout désélectionner" form_file_upload: browse: "Parcourir" edit: "Modifier" From 350275d31be27e1dadd302ed033eba7fa885ad68 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 3 Aug 2022 20:16:21 +0200 Subject: [PATCH 070/361] add is_main to product image --- app/controllers/api/products_controller.rb | 2 +- app/frontend/src/javascript/api/product.ts | 2 + .../components/form/form-image-upload.tsx | 36 +++++++++-- .../components/store/product-form.tsx | 64 +++++++++++++++++-- app/frontend/src/javascript/models/product.ts | 7 +- app/views/api/products/_product.json.jbuilder | 4 +- config/locales/app.shared.en.yml | 1 + config/locales/app.shared.fr.yml | 1 + .../20220803091913_add_is_main_to_assets.rb | 5 ++ db/schema.rb | 3 +- 10 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 db/migrate/20220803091913_add_is_main_to_assets.rb diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index b21e65be1..65a13eeb0 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -65,6 +65,6 @@ class API::ProductsController < API::ApiController :low_stock_alert, :low_stock_threshold, machine_ids: [], product_files_attributes: %i[id attachment _destroy], - product_images_attributes: %i[id attachment _destroy]) + product_images_attributes: %i[id attachment is_main _destroy]) end end diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts index c89a32642..a3c0a4e92 100644 --- a/app/frontend/src/javascript/api/product.ts +++ b/app/frontend/src/javascript/api/product.ts @@ -32,6 +32,7 @@ export default class ProductAPI { product.product_images_attributes?.forEach((image, i) => { if (image?.attachment_files && image?.attachment_files[0]) { data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]); + data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString()); } }); const res: AxiosResponse = await apiClient.post('/api/products', data, { @@ -73,6 +74,7 @@ export default class ProductAPI { if (image?._destroy) { data.set(`product[product_images_attributes][${i}][_destroy]`, image._destroy.toString()); } + data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString()); }); const res: AxiosResponse = await apiClient.patch(`/api/products/${product.id}`, data, { headers: { diff --git a/app/frontend/src/javascript/components/form/form-image-upload.tsx b/app/frontend/src/javascript/components/form/form-image-upload.tsx index 76bde2632..9214054b6 100644 --- a/app/frontend/src/javascript/components/form/form-image-upload.tsx +++ b/app/frontend/src/javascript/components/form/form-image-upload.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Path } from 'react-hook-form'; import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; @@ -13,7 +13,8 @@ import noAvatar from '../../../../images/no_avatar.png'; export interface ImageType { id?: number, attachment_name?: string, - attachment_url?: string + attachment_url?: string, + is_main?: boolean } interface FormImageUploadProps extends FormComponent, AbstractFormItemProps { @@ -21,19 +22,25 @@ interface FormImageUploadProps extends FormComponent defaultImage?: ImageType, accept?: string, size?: 'small' | 'large' + mainOption?: boolean, onFileChange?: (value: ImageType) => void, onFileRemove?: () => void, + onFileIsMain?: () => void, } /** * This component allows to upload image, in forms managed by react-hook-form. */ -export const FormImageUpload = ({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size }: FormImageUploadProps) => { +export const FormImageUpload = ({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size, onFileIsMain, mainOption = false }: FormImageUploadProps) => { const { t } = useTranslation('shared'); const [file, setFile] = useState(defaultImage); const [image, setImage] = useState(defaultImage.attachment_url); + useEffect(() => { + setFile(defaultImage); + }, [defaultImage]); + /** * Check if image is selected */ @@ -53,8 +60,13 @@ export const FormImageUpload = ({ id, register }; reader.readAsDataURL(f); setFile({ + ...file, attachment_name: f.name }); + setValue( + `${id}[attachment_name]` as Path, + f.name as UnpackNestedValue>> + ); setValue( `${id}[_destroy]` as Path, false as UnpackNestedValue>> @@ -80,12 +92,22 @@ export const FormImageUpload = ({ id, register null as UnpackNestedValue>> ); setFile(null); - setImage(null); if (typeof onFileRemove === 'function') { onFileRemove(); } } + /** + * Callback triggered when the user set the image is main + */ + function setMainImage () { + setValue( + `${id}[is_main]` as Path, + true as UnpackNestedValue>> + ); + onFileIsMain(); + } + // Compose classnames from props const classNames = [ `${className || ''}` @@ -114,6 +136,12 @@ export const FormImageUpload = ({ id, register {hasImage() && } className="delete-image" />}
+ {mainOption && +
+ + +
+ }
); }; diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index e2139568f..dc6fe7c70 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; +import { useForm, useWatch, Path } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import slugify from 'slugify'; import _ from 'lodash'; @@ -11,7 +11,7 @@ import { FormSelect } from '../form/form-select'; import { FormChecklist } from '../form/form-checklist'; import { FormRichText } from '../form/form-rich-text'; import { FormFileUpload } from '../form/form-file-upload'; -import { FormImageUpload } from '../form/form-image-upload'; +import { FormImageUpload, ImageType } from '../form/form-image-upload'; import { FabButton } from '../base/fab-button'; import { FabAlert } from '../base/fab-alert'; import ProductCategoryAPI from '../../api/product-category'; @@ -144,7 +144,9 @@ export const ProductForm: React.FC = ({ product, title, onSucc * Add new product image */ const addProductImage = () => { - setValue('product_images_attributes', output.product_images_attributes.concat({})); + setValue('product_images_attributes', output.product_images_attributes.concat({ + is_main: output.product_images_attributes.length === 0 + })); }; /** @@ -155,7 +157,59 @@ export const ProductForm: React.FC = ({ product, title, onSucc const productImage = output.product_images_attributes[i]; if (!productImage.id) { output.product_images_attributes.splice(i, 1); - setValue('product_images_attributes', output.product_images_attributes); + if (productImage.is_main) { + setValue('product_images_attributes', output.product_images_attributes.map((image, k) => { + if (k === 0) { + return { + ...image, + is_main: true + }; + } + return image; + })); + } else { + setValue('product_images_attributes', output.product_images_attributes); + } + } else { + if (productImage.is_main) { + let mainImage = false; + setValue('product_images_attributes', output.product_images_attributes.map((image, k) => { + if (i !== k && !mainImage) { + mainImage = true; + return { + ...image, + _destroy: i === k, + is_main: true + }; + } + return { + ...image, + _destroy: i === k + }; + })); + } + } + }; + }; + + /** + * Remove main image in others product images + */ + const handleSetMainImage = (i: number) => { + return () => { + if (output.product_images_attributes.length > 1) { + setValue('product_images_attributes', output.product_images_attributes.map((image, k) => { + if (i !== k) { + return { + ...image, + is_main: false + }; + } + return { + ...image, + is_main: true + }; + })); } }; }; @@ -246,7 +300,9 @@ export const ProductForm: React.FC = ({ product, title, onSucc setValue={setValue} formState={formState} className={image._destroy ? 'hidden' : ''} + mainOption={true} onFileRemove={handleRemoveProductImage(i)} + onFileIsMain={handleSetMainImage(i)} /> ))}
diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index 471e00248..57aa51809 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -27,7 +27,7 @@ export interface Product { attachment?: File, attachment_files?: FileList, attachment_name?: string, - attachment_url?: string + attachment_url?: string, _destroy?: boolean }>, product_images_attributes: Array<{ @@ -35,7 +35,8 @@ export interface Product { attachment?: File, attachment_files?: FileList, attachment_name?: string, - attachment_url?: string - _destroy?: boolean + attachment_url?: string, + _destroy?: boolean, + is_main?: boolean }> } diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder index 9b9a99336..37d9fca9b 100644 --- a/app/views/api/products/_product.json.jbuilder +++ b/app/views/api/products/_product.json.jbuilder @@ -1,6 +1,7 @@ # frozen_string_literal: true -json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold, :machine_ids +json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert, + :low_stock_threshold, :machine_ids json.amount product.amount / 100.0 if product.amount.present? json.product_files_attributes product.product_files do |f| json.id f.id @@ -11,4 +12,5 @@ json.product_images_attributes product.product_images do |f| json.id f.id json.attachment_name f.attachment_identifier json.attachment_url f.attachment_url + json.is_main f.is_main end diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 3fc1e941e..f5c3abaca 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -559,3 +559,4 @@ en: form_image_upload: browse: "Browse" edit: "Edit" + main_image: "Main image" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index 139728a02..6f2cfa3d8 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -559,3 +559,4 @@ fr: form_image_upload: browse: "Parcourir" edit: "Modifier" + main_image: "Visuel principal" diff --git a/db/migrate/20220803091913_add_is_main_to_assets.rb b/db/migrate/20220803091913_add_is_main_to_assets.rb new file mode 100644 index 000000000..3201a80a5 --- /dev/null +++ b/db/migrate/20220803091913_add_is_main_to_assets.rb @@ -0,0 +1,5 @@ +class AddIsMainToAssets < ActiveRecord::Migration[5.2] + def change + add_column :assets, :is_main, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 1222e9bcf..864a474d7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_07_20_135828) do +ActiveRecord::Schema.define(version: 2022_08_03_091913) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -70,6 +70,7 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.string "type" t.datetime "created_at" t.datetime "updated_at" + t.boolean "is_main" end create_table "auth_provider_mappings", id: :serial, force: :cascade do |t| From 851294e8d9cc5494afab954f0f223c6adcb32e1c Mon Sep 17 00:00:00 2001 From: Du Peng Date: Thu, 4 Aug 2022 09:41:53 +0200 Subject: [PATCH 071/361] add size medium to product image --- .../components/form/form-image-upload.tsx | 2 +- .../modules/form/form-image-upload.scss | 17 +++++++++++++---- app/uploaders/product_image_uploader.rb | 4 ++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/app/frontend/src/javascript/components/form/form-image-upload.tsx b/app/frontend/src/javascript/components/form/form-image-upload.tsx index 9214054b6..5ee2ead72 100644 --- a/app/frontend/src/javascript/components/form/form-image-upload.tsx +++ b/app/frontend/src/javascript/components/form/form-image-upload.tsx @@ -21,7 +21,7 @@ interface FormImageUploadProps extends FormComponent setValue: UseFormSetValue, defaultImage?: ImageType, accept?: string, - size?: 'small' | 'large' + size?: 'small' | 'medium' | 'large' mainOption?: boolean, onFileChange?: (value: ImageType) => void, onFileRemove?: () => void, diff --git a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss index fa35d9a26..9dcc481e6 100644 --- a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss +++ b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss @@ -7,13 +7,18 @@ display: inline-block; &--small img { - width: 50px; - height: 50px; + width: 80px; + height: 80px; + } + + &--medium img { + width: 200px; + height: 200px; } &--large img { - width: 180px; - height: 180px; + width: 400px; + height: 400px; } } @@ -42,6 +47,10 @@ margin: 80px 40px; } + &--medium { + margin: 80px 40px; + } + &--small { text-align: center; } diff --git a/app/uploaders/product_image_uploader.rb b/app/uploaders/product_image_uploader.rb index a7830ab81..8b591a84b 100644 --- a/app/uploaders/product_image_uploader.rb +++ b/app/uploaders/product_image_uploader.rb @@ -49,6 +49,10 @@ class ProductImageUploader < CarrierWave::Uploader::Base process resize_to_fit: [700, 400] end + version :small do + process resize_to_fit: [400, 250] + end + # Add a white list of extensions which are allowed to be uploaded. # For images you might use something like this: def extension_whitelist From ec62931a78e0f463b60927df258b2aa8ea547970 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Thu, 4 Aug 2022 14:02:19 +0200 Subject: [PATCH 072/361] fix bug: product amount cannot update --- app/controllers/api/products_controller.rb | 16 ++-------------- app/services/product_service.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index 65a13eeb0..ca15bfafb 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -15,13 +15,7 @@ class API::ProductsController < API::ApiController def create authorize Product @product = Product.new(product_params) - if @product.amount.present? - if @product.amount.zero? - @product.amount = nil - else - @product.amount *= 100 - end - end + @product.amount = ProductService.amount_multiplied_by_hundred(@product.amount) if @product.save render status: :created else @@ -33,13 +27,7 @@ class API::ProductsController < API::ApiController authorize @product product_parameters = product_params - if product_parameters[:amount].present? - if product_parameters[:amount].zero? - product_parameters[:amount] = nil - else - product_parameters[:amount] *= 100 - end - end + product_parameters[:amount] = ProductService.amount_multiplied_by_hundred(product_parameters[:amount]) if @product.update(product_parameters) render status: :ok else diff --git a/app/services/product_service.rb b/app/services/product_service.rb index d31f61ae8..8c712bf35 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -5,4 +5,16 @@ class ProductService def self.list Product.all end + + # amount params multiplied by hundred + def self.amount_multiplied_by_hundred(amount) + if amount.present? + v = amount.to_f + + return nil if v.zero? + + return v * 100 + end + nil + end end From f62244fcdbddbf161a40cb12cfe828750cd6007c Mon Sep 17 00:00:00 2001 From: Du Peng Date: Fri, 5 Aug 2022 15:25:51 +0200 Subject: [PATCH 073/361] add product stock mouvements --- app/controllers/api/products_controller.rb | 3 ++- app/frontend/src/javascript/models/product.ts | 13 ++++++++++++- app/models/product.rb | 5 ++++- app/models/product_stock_movement.rb | 18 ++++++++++++++++++ app/views/api/products/_product.json.jbuilder | 8 ++++++++ ...805083431_create_product_stock_movements.rb | 16 ++++++++++++++++ db/schema.rb | 15 ++++++++++++++- 7 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 app/models/product_stock_movement.rb create mode 100644 db/migrate/20220805083431_create_product_stock_movements.rb diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index ca15bfafb..2c5835f66 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -53,6 +53,7 @@ class API::ProductsController < API::ApiController :low_stock_alert, :low_stock_threshold, machine_ids: [], product_files_attributes: %i[id attachment _destroy], - product_images_attributes: %i[id attachment is_main _destroy]) + product_images_attributes: %i[id attachment is_main _destroy], + product_stock_movements_attributes: %i[id quantity reason stock_type _destroy]) end end diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index 57aa51809..fc951c1e5 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -1,3 +1,5 @@ +import { TDateISO } from '../typings/date-iso'; + export enum StockType { internal = 'internal', external = 'external' @@ -38,5 +40,14 @@ export interface Product { attachment_url?: string, _destroy?: boolean, is_main?: boolean - }> + }>, + product_stock_movements_attributes: Array<{ + id?: number, + quantity?: number, + reason?: string, + stock_type?: string, + remaining_stock?: number, + date?: TDateISO, + _destroy?: boolean + }>, } diff --git a/app/models/product.rb b/app/models/product.rb index 7bc4087a7..946a3c454 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -12,7 +12,10 @@ class Product < ApplicationRecord has_many :product_images, as: :viewable, dependent: :destroy accepts_nested_attributes_for :product_images, allow_destroy: true, reject_if: :all_blank - validates_numericality_of :amount, greater_than: 0, allow_nil: true + has_many :product_stock_movements, dependent: :destroy + accepts_nested_attributes_for :product_stock_movements, allow_destroy: true, reject_if: :all_blank + + validates :amount, numericality: { greater_than: 0, allow_nil: true } scope :active, -> { where(is_active: true) } end diff --git a/app/models/product_stock_movement.rb b/app/models/product_stock_movement.rb new file mode 100644 index 000000000..6d5623f2f --- /dev/null +++ b/app/models/product_stock_movement.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# ProductStockMovement is a model for record the movements of product's stock +class ProductStockMovement < ApplicationRecord + belongs_to :product + + ALL_STOCK_TYPES = %w[internal external].freeze + enum stock_type: ALL_STOCK_TYPES.zip(ALL_STOCK_TYPES).to_h + + ALL_REASONS = %w[incoming_stock returned_by_customer cancelled_by_customer sold missing_from_inventory damaged].freeze + enum reason: ALL_REASONS.zip(ALL_REASONS).to_h + + validates :stock_type, presence: true + validates :stock_type, inclusion: { in: ALL_STOCK_TYPES } + + validates :reason, presence: true + validates :reason, inclusion: { in: ALL_REASONS } +end diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder index 37d9fca9b..a6fe177cf 100644 --- a/app/views/api/products/_product.json.jbuilder +++ b/app/views/api/products/_product.json.jbuilder @@ -14,3 +14,11 @@ json.product_images_attributes product.product_images do |f| json.attachment_url f.attachment_url json.is_main f.is_main end +json.product_stock_movements_attributes product.product_stock_movements do |s| + json.id s.id + json.quantity s.quantity + json.reason s.reason + json.stock_type s.stock_type + json.remaining_stock s.remaining_stock + json.date s.date +end diff --git a/db/migrate/20220805083431_create_product_stock_movements.rb b/db/migrate/20220805083431_create_product_stock_movements.rb new file mode 100644 index 000000000..d7c7af564 --- /dev/null +++ b/db/migrate/20220805083431_create_product_stock_movements.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateProductStockMovements < ActiveRecord::Migration[5.2] + def change + create_table :product_stock_movements do |t| + t.belongs_to :product, foreign_key: true + t.integer :quantity + t.string :reason + t.string :stock_type + t.integer :remaining_stock + t.datetime :date + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 864a474d7..ca31162a3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_08_03_091913) do +ActiveRecord::Schema.define(version: 2022_08_05_083431) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -597,6 +597,18 @@ ActiveRecord::Schema.define(version: 2022_08_03_091913) do t.index ["parent_id"], name: "index_product_categories_on_parent_id" end + create_table "product_stock_movements", force: :cascade do |t| + t.bigint "product_id" + t.integer "quantity" + t.string "reason" + t.string "stock_type" + t.integer "remaining_stock" + t.datetime "date" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["product_id"], name: "index_product_stock_movements_on_product_id" + end + create_table "products", force: :cascade do |t| t.string "name" t.string "slug" @@ -1135,6 +1147,7 @@ ActiveRecord::Schema.define(version: 2022_08_03_091913) do add_foreign_key "prepaid_packs", "groups" add_foreign_key "prices", "groups" add_foreign_key "prices", "plans" + add_foreign_key "product_stock_movements", "products" add_foreign_key "products", "product_categories" add_foreign_key "project_steps", "projects" add_foreign_key "project_users", "projects" From 29993b0ec9849e66d5cbced007c23dd9668ea3fa Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 23 Aug 2022 18:55:49 +0200 Subject: [PATCH 074/361] Cleanup files --- .../components/store/product-form.tsx | 40 ++-- .../components/store/product-item.tsx | 100 ++++++++ .../components/store/products-list-header.tsx | 68 ++++++ .../components/store/products-list.tsx | 106 --------- .../javascript/components/store/products.tsx | 177 +++++++------- .../src/javascript/components/store/store.tsx | 183 ++++++++++++--- app/frontend/src/stylesheets/application.scss | 6 +- .../stylesheets/modules/store/_utilities.scss | 51 +++++ .../stylesheets/modules/store/dropOptions.md | 35 --- .../store/manage-product-category.scss | 3 - .../modules/store/product-categories.scss | 48 ++-- .../modules/store/product-form.scss | 39 ++-- .../modules/store/products-grid.scss | 9 + .../modules/store/products-list-header.scss | 51 +++++ .../modules/store/products-list.scss | 86 +------ .../stylesheets/modules/store/products.scss | 86 ++----- ...oducts-filters.scss => store-filters.scss} | 8 +- .../modules/store/store-products-list.scss | 30 +++ .../src/stylesheets/modules/store/store.scss | 216 ++++-------------- .../templates/admin/store/product_edit.html | 26 +-- .../templates/admin/store/product_new.html | 26 +-- config/locales/app.admin.en.yml | 9 +- config/locales/app.public.en.yml | 21 +- 23 files changed, 713 insertions(+), 711 deletions(-) create mode 100644 app/frontend/src/javascript/components/store/product-item.tsx create mode 100644 app/frontend/src/javascript/components/store/products-list-header.tsx delete mode 100644 app/frontend/src/javascript/components/store/products-list.tsx delete mode 100644 app/frontend/src/stylesheets/modules/store/dropOptions.md delete mode 100644 app/frontend/src/stylesheets/modules/store/manage-product-category.scss create mode 100644 app/frontend/src/stylesheets/modules/store/products-grid.scss create mode 100644 app/frontend/src/stylesheets/modules/store/products-list-header.scss rename app/frontend/src/stylesheets/modules/store/{products-filters.scss => store-filters.scss} (95%) create mode 100644 app/frontend/src/stylesheets/modules/store/store-products-list.scss diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index e4be9c953..3338d307d 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -224,21 +224,21 @@ export const ProductForm: React.FC = ({ product, title, onSucc
-
+
+ className="span-7" /> + className="span-3" />
-
+
= ({ product, title, onSucc
-
+

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

= ({ product, title, onSucc onChange={toggleIsActivePrice} className='span-3' />
- {isActivePrice &&
-
- - -
+ {isActivePrice &&
+ +
}
diff --git a/app/frontend/src/javascript/components/store/product-item.tsx b/app/frontend/src/javascript/components/store/product-item.tsx new file mode 100644 index 000000000..eae1d340c --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-item.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import FormatLib from '../../lib/format'; +import { FabButton } from '../base/fab-button'; +import { Product } from '../../models/product'; +import { PencilSimple, Trash } from 'phosphor-react'; +import noImage from '../../../../images/no_image.png'; + +interface ProductItemProps { + product: Product, + onEdit: (product: Product) => void, + onDelete: (productId: number) => void, +} + +/** + * This component shows a product item in the admin view + */ +export const ProductItem: React.FC = ({ product, onEdit, onDelete }) => { + const { t } = useTranslation('admin'); + + /** + * Get the main image + */ + const thumbnail = () => { + const image = product.product_images_attributes + .find(att => att.is_main); + return image; + }; + /** + * Init the process of editing the given product + */ + const editProduct = (product: Product): () => void => { + return (): void => { + onEdit(product); + }; + }; + + /** + * Init the process of delete the given product + */ + const deleteProduct = (productId: number): () => void => { + return (): void => { + onDelete(productId); + }; + }; + + /** + * Returns CSS class from stock status + */ + const statusColor = (product: Product) => { + if (product.stock.external === 0 && product.stock.internal === 0) { + return 'out-of-stock'; + } + if (product.low_stock_alert) { + return 'low'; + } + }; + + return ( +
+
+ {/* TODO: image size version ? */} + +

{product.name}

+
+
+ + {product.is_active + ? t('app.admin.store.product_item.visible') + : t('app.admin.store.product_item.hidden') + } + +
+ {t('app.admin.store.product_item.stock.internal')} +

{product.stock.internal}

+
+
+ {t('app.admin.store.product_item.stock.external')} +

{product.stock.external}

+
+ {product.amount && +
+

{FormatLib.price(product.amount)}

+ / {t('app.admin.store.product_item.unit')} +
+ } +
+
+
+ + + + + + +
+
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/products-list-header.tsx b/app/frontend/src/javascript/components/store/products-list-header.tsx new file mode 100644 index 000000000..17b9c221a --- /dev/null +++ b/app/frontend/src/javascript/components/store/products-list-header.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import Select from 'react-select'; +import Switch from 'react-switch'; + +interface ProductsListHeaderProps { + productsCount: number, + selectOptions: selectOption[], + onSelectOptionsChange: (option: selectOption) => void, + switchLabel?: string, + onSwitch: (boolean) => void +} +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ + type selectOption = { value: number, label: string }; + +/** + * Renders an accordion item + */ +export const ProductsListHeader: React.FC = ({ productsCount, selectOptions, onSelectOptionsChange, switchLabel, onSwitch }) => { + const { t } = useTranslation('admin'); + + // Styles the React-select component + const customStyles = { + control: base => ({ + ...base, + width: '20ch', + border: 'none', + backgroundColor: 'transparent' + }), + indicatorSeparator: () => ({ + display: 'none' + }) + }; + + return ( +
+
+

{t('app.admin.store.products_list_header.result_count')}{productsCount}

+
+
+
+

{t('app.admin.store.products_list_header.display_options')}

+ handleSelectCategory(pc, event.target.checked)} /> -

{pc.name}

- - ))} -
- {t('app.admin.store.products.filter_apply')} +
+
+

{t('app.admin.store.products.filter')}

+
+ {t('app.admin.store.products.filter_clear')} +
+
+
+ +
+
+ {productCategories.map(pc => ( + + ))}
- + {t('app.admin.store.products.filter_apply')} +
+
- -
-
- {machines.map(m => ( - - ))} -
- {t('app.admin.store.products.filter_apply')} + +
+
+ {machines.map(m => ( + + ))}
- -
+ {t('app.admin.store.products.filter_apply')} +
+
-
-
-
-

{t('app.admin.store.products.result_count')}{filteredProductsList.length}

+
+
+ +
+ {features.categories.map(c => ( +
+

{c.name}

+
-
-
-

{t('app.admin.store.products.display_options')}

- +

{pc.name}

+ + ))} +
+ {t('app.public.store.products.filter_apply')}
- + + +
+
+ {machines.map(m => ( + + ))} +
+ {t('app.public.store.products.filter_apply')} +
+
+
+
+
+ +
+
+

feature name

+ +
+
+

long feature name

+ +
-
-
-
-

Result count: {products.length}

-
-
-
-

Display options:

-
-
-
-
-
-
-
-

feature name

- -
-
-

long feature name

- -
-
- -
- {products.map((product) => ( - - ))} -
+
+ {products.map((product) => ( + + ))}
); }; +/** + * Option format, expected by checklist + */ + type checklistOption = { value: number, label: string }; +/** + * Convert the provided array of items to the checklist format + */ +const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array => { + return items.map(t => { + return { value: t.id, label: t.name }; + }); +}; + const StoreWrapper: React.FC = (props) => { return ( diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 4f10d4086..9be7382e9 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -89,12 +89,14 @@ @import "modules/settings/user-validation-setting"; @import "modules/socials/fab-socials"; @import "modules/store/_utilities"; -@import "modules/store/manage-product-category"; @import "modules/store/product-categories"; @import "modules/store/product-form"; -@import "modules/store/products-filters"; +@import "modules/store/products-grid"; +@import "modules/store/products-list-header"; @import "modules/store/products-list"; @import "modules/store/products"; +@import "modules/store/store-filters"; +@import "modules/store/store-products-list"; @import "modules/store/store"; @import "modules/subscriptions/free-extend-modal"; @import "modules/subscriptions/renew-modal"; diff --git a/app/frontend/src/stylesheets/modules/store/_utilities.scss b/app/frontend/src/stylesheets/modules/store/_utilities.scss index 12cff16e3..8dc99e1e6 100644 --- a/app/frontend/src/stylesheets/modules/store/_utilities.scss +++ b/app/frontend/src/stylesheets/modules/store/_utilities.scss @@ -11,4 +11,55 @@ color: currentColor; box-shadow: none; } +} + +@mixin grid-col($col-count) { + width: 100%; + display: grid; + grid-template-columns: repeat($col-count, minmax(0, 1fr)); +} + +.back-btn { + margin: 2.4rem 0; + padding: 0.4rem 0.8rem; + display: inline-flex; + align-items: center; + background-color: var(--gray-soft-darkest); + border-radius: var(--border-radius-sm); + color: var(--gray-soft-lightest); + i { margin-right: 0.8rem; } + + &:hover { + color: var(--gray-soft-lightest); + background-color: var(--gray-hard-lightest); + cursor: pointer; + } +} + +.main-action-btn { + background-color: var(--main); + color: var(--gray-soft-lightest); + border: none; + &:hover { opacity: 0.75; } +} + +@mixin header { + padding: 2.4rem 0; + display: flex; + justify-content: space-between; + align-items: center; + .grpBtn { + display: flex; + & > *:not(:first-child) { margin-left: 2.4rem; } + } + h2 { + margin: 0; + @include title-lg; + color: var(--gray-hard-darkest) !important; + } + h3 { + margin: 0; + @include text-lg(600); + color: var(--gray-hard-darkest) !important; + } } \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/dropOptions.md b/app/frontend/src/stylesheets/modules/store/dropOptions.md deleted file mode 100644 index d8ff63b24..000000000 --- a/app/frontend/src/stylesheets/modules/store/dropOptions.md +++ /dev/null @@ -1,35 +0,0 @@ - - -## [A] Single |> [B] Single - [A] = index de [B] - offset && [A] child de [B] - - - - - -## [A] Child |> [B] Single - [A] = index de [B] - offset - ? [A] child de [B] - : [A] Single - - - - - - - -## [A] Single |> [A] - offset && [A] child du précédant parent \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/manage-product-category.scss b/app/frontend/src/stylesheets/modules/store/manage-product-category.scss deleted file mode 100644 index 41a61d564..000000000 --- a/app/frontend/src/stylesheets/modules/store/manage-product-category.scss +++ /dev/null @@ -1,3 +0,0 @@ -.manage-product-category { - -} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index fb0148f61..1098f1e99 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -1,40 +1,30 @@ .product-categories { - max-width: 1300px; + max-width: 1600px; margin: 0 auto; + padding-bottom: 6rem; + @include grid-col(12); + gap: 0 3.2rem; header { - padding: 2.4rem 0; - display: flex; - justify-content: space-between; - align-items: center; - .grpBtn { - display: flex; - & > *:not(:first-child) { margin-left: 2.4rem; } - .create-button { - background-color: var(--gray-hard-darkest); - border-color: var(--gray-hard-darkest); - color: var(--gray-soft-lightest); - &:hover { - background-color: var(--gray-hard-light); - border-color: var(--gray-hard-light); - } - } - } - h2 { - margin: 0; - @include title-lg; - color: var(--gray-hard-darkest) !important; - } - } + @include header(); + grid-column: 2 / -2; - .main-action-btn { - background-color: var(--main); - color: var(--gray-soft-lightest); - border: none; - &:hover { opacity: 0.75; } + .create-button { + background-color: var(--gray-hard-darkest); + border-color: var(--gray-hard-darkest); + color: var(--gray-soft-lightest); + &:hover { + background-color: var(--gray-hard-light); + border-color: var(--gray-hard-light); + } + } + } + .fab-alert { + grid-column: 2 / -2; } &-tree { + grid-column: 2 / -2; & > *:not(:first-child) { margin-top: 1.6rem; } diff --git a/app/frontend/src/stylesheets/modules/store/product-form.scss b/app/frontend/src/stylesheets/modules/store/product-form.scss index 17f3adfbe..5e1686e07 100644 --- a/app/frontend/src/stylesheets/modules/store/product-form.scss +++ b/app/frontend/src/stylesheets/modules/store/product-form.scss @@ -1,4 +1,5 @@ .product-form { + grid-column: 2 / -2; h4 { margin: 0 0 2.4rem; @include title-base; @@ -7,6 +8,18 @@ margin: 4.8rem 0; } + .subgrid { + @include grid-col(10); + gap: 3.2rem; + align-items: flex-end; + } + .span-3 { grid-column: span 3; } + .span-7 { grid-column: span 7; } + + & > div { + grid-column: 2 / -2; + } + .flex { display: flex; flex-wrap: wrap; @@ -16,23 +29,17 @@ flex: 1 1 320px; } } - - .layout { - @media (max-width: 1023px) { - .span-3, - .span-7 { - flex-basis: 50%; - } - } - @media (max-width: 767px) { - flex-wrap: wrap; - } - } - .price-data { - .layout { - align-items: center; - } + .price-data-header { + @include grid-col(10); + gap: 3.2rem; + align-items: center; + } + .price-data-content { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 0 3.2rem; + align-items: flex-end; } .product-images, diff --git a/app/frontend/src/stylesheets/modules/store/products-grid.scss b/app/frontend/src/stylesheets/modules/store/products-grid.scss new file mode 100644 index 000000000..485d811c2 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/products-grid.scss @@ -0,0 +1,9 @@ +.products-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 3.2rem; + + .store-product-item { + color: tomato; + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/products-list-header.scss b/app/frontend/src/stylesheets/modules/store/products-list-header.scss new file mode 100644 index 000000000..01a85ac98 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/products-list-header.scss @@ -0,0 +1,51 @@ +.products-list-header { + padding: 0.8rem 2.4rem; + display: flex; + justify-content: space-between; + background-color: var(--gray-soft); + border-radius: var(--border-radius); + p { margin: 0; } + + .count { + display: flex; + align-items: center; + p { + @include text-sm; + span { + margin-left: 1.6rem; + @include text-lg(600); + } + } + } + + .display { + display: flex; + align-items: center; + & > *:not(:first-child) { + margin-left: 2rem; + padding-left: 2rem; + border-left: 1px solid var(--gray-hard-darkest); + } + + .sort { + display: flex; + align-items: center; + p { margin-right: 0.8rem; } + } + + .visibility { + display: flex; + align-items: center; + label { + margin: 0; + display: flex; + align-items: center; + font-weight: 400; + cursor: pointer; + span { + margin-right: 1rem; + } + } + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/products-list.scss b/app/frontend/src/stylesheets/modules/store/products-list.scss index 49653349b..0642388aa 100644 --- a/app/frontend/src/stylesheets/modules/store/products-list.scss +++ b/app/frontend/src/stylesheets/modules/store/products-list.scss @@ -1,88 +1,13 @@ .products-list { - .status { - padding: 0.8rem 2.4rem; - display: flex; - justify-content: space-between; - background-color: var(--gray-soft); - border-radius: var(--border-radius); - p { margin: 0; } - - .count { - display: flex; - align-items: center; - p { - @include text-sm; - span { - margin-left: 1.6rem; - @include text-lg(600); - } - } - } - - .display { - display: flex; - align-items: center; - & > *:not(:first-child) { - margin-left: 2rem; - padding-left: 2rem; - border-left: 1px solid var(--gray-hard-darkest); - } - - .sort { - display: flex; - align-items: center; - p { margin-right: 0.8rem; } - } - - .visibility { - display: flex; - align-items: center; - label { - margin: 0; - display: flex; - align-items: center; - font-weight: 400; - cursor: pointer; - span { - margin-right: 1rem; - } - } - } - } + & > *:not(:first-child) { + margin-top: 1.6rem; } - - .features { - margin: 2.4rem 0 1.6rem; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 1.6rem 2.4rem; - &-item { - padding-left: 1.6rem; - display: flex; - align-items: center; - background-color: var(--information-light); - border-radius: 100px; - color: var(--information-dark); - overflow: hidden; - p { margin: 0; } - button { - width: 3.2rem; - height: 3.2rem; - margin-left: 0.8rem; - display: flex; - align-items: center; - background: none; - border: none; - } - } - } - - &-item { + .product-item { --status-color: var(--gray-hard-darkest); &.low { --status-color: var(--alert-light); } &.out-of-stock { --status-color: var(--alert); } + width: 100%; display: flex; justify-content: space-between; align-items: center; @@ -91,9 +16,6 @@ border-radius: var(--border-radius); background-color: var(--gray-soft-lightest); &.out-of-stock { border-color: var(--status-color); } - &:not(:first-child) { - margin-top: 1.6rem; - } .itemInfo { min-width: 20ch; diff --git a/app/frontend/src/stylesheets/modules/store/products.scss b/app/frontend/src/stylesheets/modules/store/products.scss index 4db7b4c12..39f1631a8 100644 --- a/app/frontend/src/stylesheets/modules/store/products.scss +++ b/app/frontend/src/stylesheets/modules/store/products.scss @@ -1,82 +1,30 @@ .products, .new-product, .edit-product { + max-width: 1600px; margin: 0 auto; padding-bottom: 6rem; - - .back-btn { - margin: 2.4rem 0; - padding: 0.4rem 0.8rem; - display: inline-flex; - align-items: center; - background-color: var(--gray-soft-darkest); - border-radius: var(--border-radius-sm); - color: var(--gray-soft-lightest); - i { margin-right: 0.8rem; } - - &:hover { - background-color: var(--gray-hard-lightest); - cursor: pointer; - } - } + @include grid-col(12); + gap: 3.2rem; header { - padding: 2.4rem 0; - display: flex; - justify-content: space-between; - align-items: center; - .grpBtn { - display: flex; - & > *:not(:first-child) { margin-left: 2.4rem; } - } - h2 { - margin: 0; - @include title-lg; - color: var(--gray-hard-darkest) !important; - } - h3 { - margin: 0; - @include text-lg(600); - color: var(--gray-hard-darkest) !important; - } - } - - .layout { - display: flex; - align-items: flex-end; - gap: 0 3.2rem; - .span-7 { flex: 1 1 70%; } - .span-3 { flex: 1 1 30%; } - } - - .main-action-btn { - background-color: var(--main); - color: var(--gray-soft-lightest); - border: none; - &:hover { opacity: 0.75; } - } - - .main-actions { - display: flex; - justify-content: center; - align-items: center; - & > *:not(:first-child) { - margin-left: 1.6rem; - } - } -} - -.products { - max-width: 1600px; - - .layout { - align-items: flex-start; + @include header(); + grid-column: 1 / -1; } } .new-product, .edit-product { - max-width: 1300px; - padding-right: 1.6rem; - padding-left: 1.6rem; + + &-nav { + max-width: 1600px; + margin: 0 auto; + @include grid-col(12); + justify-items: flex-start; + & > * { + grid-column: 2 / -2; + } + } + + header { grid-column: 2 / -2; } } diff --git a/app/frontend/src/stylesheets/modules/store/products-filters.scss b/app/frontend/src/stylesheets/modules/store/store-filters.scss similarity index 95% rename from app/frontend/src/stylesheets/modules/store/products-filters.scss rename to app/frontend/src/stylesheets/modules/store/store-filters.scss index 352b5c913..784c8475d 100644 --- a/app/frontend/src/stylesheets/modules/store/products-filters.scss +++ b/app/frontend/src/stylesheets/modules/store/store-filters.scss @@ -1,7 +1,13 @@ -.products-filters { +.store-filters { + grid-column: 1 / 4; padding-top: 1.6rem; border-top: 1px solid var(--gray-soft-dark); + header { + @include header(); + padding: 0 0 2.4rem 0; + } + .accordion { &-item:not(:last-of-type) { margin-bottom: 1.6rem; diff --git a/app/frontend/src/stylesheets/modules/store/store-products-list.scss b/app/frontend/src/stylesheets/modules/store/store-products-list.scss new file mode 100644 index 000000000..50baf2efc --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/store-products-list.scss @@ -0,0 +1,30 @@ +.store-products-list { + grid-column: 4 / -1; + .features { + margin: 2.4rem 0 1.6rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1.6rem 2.4rem; + &-item { + padding-left: 1.6rem; + display: flex; + align-items: center; + background-color: var(--information-light); + border-radius: 100px; + color: var(--information-dark); + overflow: hidden; + p { margin: 0; } + button { + width: 3.2rem; + height: 3.2rem; + margin-left: 0.8rem; + display: flex; + align-items: center; + background: none; + border: none; + } + } + } + +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/store.scss b/app/frontend/src/stylesheets/modules/store/store.scss index e31405226..cf3d306f5 100644 --- a/app/frontend/src/stylesheets/modules/store/store.scss +++ b/app/frontend/src/stylesheets/modules/store/store.scss @@ -1,170 +1,52 @@ -.store { - margin: 0 auto; - padding-bottom: 6rem; - - .back-btn { - margin: 2.4rem 0; - padding: 0.4rem 0.8rem; - display: inline-flex; - align-items: center; - background-color: var(--gray-soft-darkest); - border-radius: var(--border-radius-sm); - color: var(--gray-soft-lightest); - i { margin-right: 0.8rem; } - - &:hover { - background-color: var(--gray-hard-lightest); - cursor: pointer; - } - } - - header { - padding: 2.4rem 0; - display: flex; - justify-content: space-between; - align-items: center; - .grpBtn { - display: flex; - & > *:not(:first-child) { margin-left: 2.4rem; } - } - h2 { - margin: 0; - @include title-lg; - color: var(--gray-hard-darkest) !important; - } - h3 { - margin: 0; - @include text-lg(600); - color: var(--gray-hard-darkest) !important; - } - } - - .layout { - display: flex; - align-items: flex-end; - gap: 0 3.2rem; - .span-7 { flex: 1 1 70%; } - .span-3 { flex: 1 1 30%; } - } - - .main-action-btn { - background-color: var(--main); - color: var(--gray-soft-lightest); - border: none; - &:hover { opacity: 0.75; } - } - - .main-actions { - display: flex; - justify-content: center; - align-items: center; - & > *:not(:first-child) { - margin-left: 1.6rem; - } - } -} - .store { max-width: 1600px; - - .layout { - align-items: flex-start; - } - - &-filters { - } - - &-products-list { - .products { - display: flex; - flex-wrap: wrap; - } - - .status { - padding: 1.6rem 2.4rem; - display: flex; - justify-content: space-between; - background-color: var(--gray-soft); - border-radius: var(--border-radius); - p { margin: 0; } - .count { - p { - display: flex; - align-items: center; - @include text-sm; - span { - margin-left: 1.6rem; - @include text-lg(600); - } - } - } - } - .features { - margin: 2.4rem 0 1.6rem; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 1.6rem 2.4rem; - &-item { - padding-left: 1.6rem; - display: flex; - align-items: center; - background-color: var(--information-light); - border-radius: 100px; - color: var(--information-dark); - p { margin: 0; } - button { - width: 3.2rem; - height: 3.2rem; - background: none; - border: none; - } - } - } - } - - &-product-item { - padding: 1rem 1.8rem; - border: 1px solid var(--gray-soft-dark); - border-radius: var(--border-radius); - background-color: var(--gray-soft-lightest); - - margin-right: 1.6rem; - - .itemInfo-image { - align-items: center; - - img { - width: 19.8rem; - height: 14.8rem; - object-fit: cover; - border-radius: var(--border-radius); - background-color: var(--gray-soft); - } - } - .itemInfo-name { - margin: 1rem 0; - @include text-base; - font-weight: 600; - color: var(--gray-hard-darkest); - } - - .actions { - display: flex; - align-items: center; - .manage { - overflow: hidden; - display: flex; - border-radius: var(--border-radius-sm); - button { - @include btn; - border-radius: 0; - color: var(--gray-soft-lightest); - &:hover { opacity: 0.75; } - } - .edit-btn {background: var(--gray-hard-darkest) } - .delete-btn {background: var(--error) } - } - } - } + @include grid-col(12); + gap: 3.2rem; + margin: 0 auto; + padding-bottom: 6rem; + + //&-product-item { + // padding: 1rem 1.8rem; + // border: 1px solid var(--gray-soft-dark); + // border-radius: var(--border-radius); + // background-color: var(--gray-soft-lightest); + + // margin-right: 1.6rem; + + // .itemInfo-image { + // align-items: center; + + // img { + // width: 19.8rem; + // height: 14.8rem; + // object-fit: cover; + // border-radius: var(--border-radius); + // background-color: var(--gray-soft); + // } + // } + // .itemInfo-name { + // margin: 1rem 0; + // @include text-base; + // font-weight: 600; + // color: var(--gray-hard-darkest); + // } + + // .actions { + // display: flex; + // align-items: center; + // .manage { + // overflow: hidden; + // display: flex; + // border-radius: var(--border-radius-sm); + // button { + // @include btn; + // border-radius: 0; + // color: var(--gray-soft-lightest); + // &:hover { opacity: 0.75; } + // } + // .edit-btn {background: var(--gray-hard-darkest) } + // .delete-btn {background: var(--error) } + // } + // } + //} } diff --git a/app/frontend/templates/admin/store/product_edit.html b/app/frontend/templates/admin/store/product_edit.html index 6c2f86d18..8f4318026 100644 --- a/app/frontend/templates/admin/store/product_edit.html +++ b/app/frontend/templates/admin/store/product_edit.html @@ -14,22 +14,12 @@
-
-
\ No newline at end of file diff --git a/app/frontend/templates/admin/store/product_new.html b/app/frontend/templates/admin/store/product_new.html index 8c4fb9a05..40d3c5b6b 100644 --- a/app/frontend/templates/admin/store/product_new.html +++ b/app/frontend/templates/admin/store/product_new.html @@ -14,22 +14,12 @@
-
-
\ No newline at end of file diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 3c6a85be1..07bb3b312 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1942,15 +1942,16 @@ en: filter_stock: "By stock status" filter_stock_from: "From" filter_stock_to: "to" - result_count: "Result count:" - display_options: "Display options:" - visible_only: "Visible products only" sort: name_az: "A-Z" name_za: "Z-A" price_low: "Price: low to high" price_high: "Price: high to low" - products_list: + products_list_header: + result_count: "Result count:" + display_options: "Display options:" + visible_only: "Visible products only" + product_item: visible: "visible" hidden: "hidden" stock: diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index ac9bf7ab1..a8aec1645 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -378,11 +378,22 @@ en: store: fablab_store: "FabLab Store" unexpected_error_occurred: "An unexpected error occurred. Please try again later." - store_product_item: - available: "Available" - limited_stock: "Limited stock" - out_of_stock: "Out of stock" - add: "Add" + products: + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "By categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + filter_stock: "By stock status" + filter_stock_from: "From" + filter_stock_to: "to" + in_stock_only: "Available products only" + sort: + name_az: "A-Z" + name_za: "Z-A" + price_low: "Price: low to high" + price_high: "Price: high to low" store_product: unexpected_error_occurred: "An unexpected error occurred. Please try again later." cart: From e0dc008d4c2d7eaa41a73812794c9d20c3c7b76a Mon Sep 17 00:00:00 2001 From: vincent Date: Wed, 24 Aug 2022 18:34:34 +0200 Subject: [PATCH 075/361] Client side product list + product view --- .../javascript/components/cart/store-cart.tsx | 1 + .../components/store/product-form.tsx | 2 +- .../components/store/product-item.tsx | 6 +- .../components/store/store-product-item.tsx | 74 +++--- .../components/store/store-product.tsx | 146 +++++++++++- .../src/javascript/components/store/store.tsx | 13 +- .../stylesheets/modules/store/_utilities.scss | 6 +- .../modules/store/products-grid.scss | 80 ++++++- .../modules/store/products-list.scss | 8 +- .../src/stylesheets/modules/store/store.scss | 220 ++++++++++++++---- .../src/stylesheets/variables/typography.scss | 2 +- app/frontend/templates/products/show.html | 2 +- config/locales/app.public.en.yml | 5 + 13 files changed, 469 insertions(+), 96 deletions(-) diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index d24adffc2..282d106f6 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index 3338d307d..c97252d05 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -352,7 +352,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc
diff --git a/app/frontend/src/javascript/components/store/product-item.tsx b/app/frontend/src/javascript/components/store/product-item.tsx index eae1d340c..47022bb61 100644 --- a/app/frontend/src/javascript/components/store/product-item.tsx +++ b/app/frontend/src/javascript/components/store/product-item.tsx @@ -51,7 +51,7 @@ export const ProductItem: React.FC = ({ product, onEdit, onDel if (product.stock.external === 0 && product.stock.internal === 0) { return 'out-of-stock'; } - if (product.low_stock_alert) { + if (product.low_stock_threshold && (product.stock.external < product.low_stock_threshold || product.stock.internal < product.low_stock_threshold)) { return 'low'; } }; @@ -70,11 +70,11 @@ export const ProductItem: React.FC = ({ product, onEdit, onDel : t('app.admin.store.product_item.hidden') } -
+
{t('app.admin.store.product_item.stock.internal')}

{product.stock.internal}

-
+
{t('app.admin.store.product_item.stock.external')}

{product.stock.external}

diff --git a/app/frontend/src/javascript/components/store/store-product-item.tsx b/app/frontend/src/javascript/components/store/store-product-item.tsx index 1c8a0e8fa..5b46bc006 100644 --- a/app/frontend/src/javascript/components/store/store-product-item.tsx +++ b/app/frontend/src/javascript/components/store/store-product-item.tsx @@ -6,6 +6,7 @@ import { Product } from '../../models/product'; import { Order } from '../../models/order'; import FormatLib from '../../lib/format'; import CartAPI from '../../api/cart'; +import noImage from '../../../../images/no_image.png'; interface StoreProductItemProps { product: Product, @@ -20,27 +21,14 @@ export const StoreProductItem: React.FC = ({ product, car const { t } = useTranslation('public'); /** - * Return main image of Product, if the product has not any image, show default image + * Return main image of Product, if the product has no image, show default image */ const productImageUrl = (product: Product) => { const productImage = _.find(product.product_images_attributes, { is_main: true }); if (productImage) { return productImage.attachment_url; } - return 'https://via.placeholder.com/300'; - }; - - /** - * Return product's stock status - */ - const productStockStatus = (product: Product) => { - if (product.stock.external === 0) { - return {t('app.public.store_product_item.out_of_stock')}; - } - if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) { - return {t('app.public.store_product_item.limited_stock')}; - } - return {t('app.public.store_product_item.available')}; + return noImage; }; /** @@ -59,23 +47,51 @@ export const StoreProductItem: React.FC = ({ product, car window.location.href = `/#!/store/p/${product.slug}`; }; + /** + * Returns CSS class from stock status + */ + const statusColor = (product: Product) => { + if (product.stock.external === 0 && product.stock.internal === 0) { + return 'out-of-stock'; + } + if (product.low_stock_alert) { + return 'low'; + } + }; + + /** + * Return product's stock status + */ + const productStockStatus = (product: Product) => { + if (product.stock.external === 0) { + return {t('app.public.store_product_item.out_of_stock')}; + } + if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) { + return {t('app.public.store_product_item.limited_stock')}; + } + return {t('app.public.store_product_item.available')}; + }; + return ( -
showProduct(product)}> -
- +
showProduct(product)}> +
+
-

{product.name}

-
- -
{FormatLib.price(product.amount)}
- {productStockStatus(product)} -
- {product.stock.external > 0 && - - {t('app.public.store_product_item.add')} - - } +

{product.name}

+ {product.amount && +
+

{FormatLib.price(product.amount)}

+ / {t('app.public.store_product_item.unit')} +
+ } +
+ {productStockStatus(product)}
+ {product.stock.external > 0 && + } className="main-action-btn" onClick={addProductToCart}> + {t('app.public.store_product_item.add')} + + }
); }; diff --git a/app/frontend/src/javascript/components/store/store-product.tsx b/app/frontend/src/javascript/components/store/store-product.tsx index d9c6bc69c..237557cb8 100644 --- a/app/frontend/src/javascript/components/store/store-product.tsx +++ b/app/frontend/src/javascript/components/store/store-product.tsx @@ -1,11 +1,16 @@ -import React, { useEffect, useState } from 'react'; +/* eslint-disable fabmanager/scoped-translation */ +import React, { useEffect, useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import FormatLib from '../../lib/format'; import { react2angular } from 'react2angular'; import { Loader } from '../base/loader'; import { IApplication } from '../../models/application'; import _ from 'lodash'; import { Product } from '../../models/product'; import ProductAPI from '../../api/product'; +import noImage from '../../../../images/no_image.png'; +import { FabButton } from '../base/fab-button'; +import { FilePdf, Minus, Plus } from 'phosphor-react'; declare const Application: IApplication; @@ -21,32 +26,155 @@ export const StoreProduct: React.FC = ({ productSlug, onError const { t } = useTranslation('public'); const [product, setProduct] = useState(); + console.log('product: ', product); + const [showImage, setShowImage] = useState(null); + const [toCartCount, setToCartCount] = useState(0); + const [displayToggle, setDisplayToggle] = useState(false); + const [collapseDescription, setCollapseDescription] = useState(true); + const descContainer = useRef(null); useEffect(() => { ProductAPI.get(productSlug).then(data => { setProduct(data); + const productImage = _.find(data.product_images_attributes, { is_main: true }); + setShowImage(productImage.id); + setToCartCount(data.quantity_min ? data.quantity_min : 1); + setDisplayToggle(descContainer.current.offsetHeight < descContainer.current.scrollHeight); }).catch(() => { onError(t('app.public.store_product.unexpected_error_occurred')); }); }, []); /** - * Return main image of Product, if the product has not any image, show default image + * Return main image of Product, if the product has no image, show default image */ - const productImageUrl = (product: Product) => { - const productImage = _.find(product.product_images_attributes, { is_main: true }); + const productImageUrl = (id: number) => { + const productImage = _.find(product.product_images_attributes, { id }); if (productImage) { return productImage.attachment_url; } - return 'https://via.placeholder.com/300'; + return noImage; + }; + + /** + * Returns CSS class from stock status + */ + const statusColor = (product: Product) => { + if (product.stock.external === 0 && product.stock.internal === 0) { + return 'out-of-stock'; + } + if (product.low_stock_alert) { + return 'low'; + } + }; + + /** + * Return product's stock status + */ + const productStockStatus = (product: Product) => { + if (product.stock.external === 0) { + return {t('app.public.store_product_item.out_of_stock')}; + } + if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) { + return {t('app.public.store_product_item.limited_stock')}; + } + return {t('app.public.store_product_item.available')}; + }; + + /** + * Update product count + */ + const setCount = (type: 'add' | 'remove') => { + switch (type) { + case 'add': + setToCartCount(toCartCount + 1); + break; + case 'remove': + if (toCartCount > product.quantity_min) { + setToCartCount(toCartCount - 1); + } + break; + } + }; + /** + * Update product count + */ + const typeCount = (evt: React.ChangeEvent) => { + evt.preventDefault(); + setToCartCount(Number(evt.target.value)); }; if (product) { return ( -
- -

{product.name}

-
+
+ ref: {product.sku} +

{product.name}

+
+
+
+ +
+
+ {product.product_images_attributes.length > 1 && +
+ {product.product_images_attributes.map(i => ( +
+ setShowImage(i.id)} src={i.attachment_url} /> +
+ ))} +
+ } +
+
+
+ {displayToggle && + + } + {product.product_files_attributes.length > 0 && +
+

{t('app.public.store_product.documentation')}

+
+ {product.product_files_attributes.map(f => + + + {f.attachment_name} + + )} +
+
+ } +
+ +
); } diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx index d9581eade..bcb1f6ee0 100644 --- a/app/frontend/src/javascript/components/store/store.tsx +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -49,7 +49,18 @@ const Store: React.FC = ({ onError, currentUser }) => { }); ProductCategoryAPI.index().then(data => { - setProductCategories(data); + // Map product categories by position + const sortedCategories = data + .filter(c => !c.parent_id) + .sort((a, b) => a.position - b.position); + const childrenCategories = data + .filter(c => typeof c.parent_id === 'number') + .sort((a, b) => b.position - a.position); + childrenCategories.forEach(c => { + const parentIndex = sortedCategories.findIndex(i => i.id === c.parent_id); + sortedCategories.splice(parentIndex + 1, 0, c); + }); + setProductCategories(sortedCategories); }).catch(() => { onError(t('app.public.store.unexpected_error_occurred')); }); diff --git a/app/frontend/src/stylesheets/modules/store/_utilities.scss b/app/frontend/src/stylesheets/modules/store/_utilities.scss index 8dc99e1e6..dd9941aa8 100644 --- a/app/frontend/src/stylesheets/modules/store/_utilities.scss +++ b/app/frontend/src/stylesheets/modules/store/_utilities.scss @@ -40,7 +40,11 @@ background-color: var(--main); color: var(--gray-soft-lightest); border: none; - &:hover { opacity: 0.75; } + &:hover { + background-color: var(--main); + color: var(--gray-soft-lightest); + opacity: 0.75; + } } @mixin header { diff --git a/app/frontend/src/stylesheets/modules/store/products-grid.scss b/app/frontend/src/stylesheets/modules/store/products-grid.scss index 485d811c2..cf11a6843 100644 --- a/app/frontend/src/stylesheets/modules/store/products-grid.scss +++ b/app/frontend/src/stylesheets/modules/store/products-grid.scss @@ -1,9 +1,85 @@ .products-grid { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 3.2rem; .store-product-item { - color: tomato; + --status-color: var(--success); + &.low { --status-color: var(--alert-light); } + &.out-of-stock { + --status-color: var(--alert); + background-color: var(--gray-soft-light); + border: none; + } + + padding: 1.6rem 2.4rem; + display: grid; + grid-template-areas: "image image" + "name name" + "price btn" + "stock btn"; + grid-template-columns: auto min-content; + grid-template-rows: min-content auto min-content min-content; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + cursor: pointer; + + .picture { + grid-area: image; + position: relative; + width: 100%; + height: 0; + padding-bottom: 50%; + border-radius: var(--border-radius); + overflow: hidden; + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + } + .name { + margin: 2.4rem 0 1.6rem; + grid-area: name; + align-self: flex-start; + @include text-base(600); + } + .price { + grid-area: price; + display: flex; + align-items: baseline; + p { + margin: 0; + @include title-base; + } + span { + margin-left: 0.8rem; + @include text-sm; + word-break: break-all; + } + } + .stock { + grid-area: stock; + display: flex; + align-items: center; + @include text-sm; + color: var(--status-color); + &::before { + content: ""; + margin-right: 0.8rem; + width: 1rem; + height: 1rem; + background-color: var(--status-color); + border-radius: 50%; + } + } + button { + grid-area: btn; + align-self: flex-end; + margin-left: 1rem; + i { margin-right: 0.8rem; } + } } } \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/products-list.scss b/app/frontend/src/stylesheets/modules/store/products-list.scss index 0642388aa..88b49d64e 100644 --- a/app/frontend/src/stylesheets/modules/store/products-list.scss +++ b/app/frontend/src/stylesheets/modules/store/products-list.scss @@ -5,7 +5,10 @@ .product-item { --status-color: var(--gray-hard-darkest); &.low { --status-color: var(--alert-light); } - &.out-of-stock { --status-color: var(--alert); } + &.out-of-stock { + --status-color: var(--alert); + .stock { color: var(--alert) !important; } + } width: 100%; display: flex; @@ -74,8 +77,9 @@ .stock { display: flex; flex-direction: column; - color: var(--status-color); + color: var(--gray-hard-darkest); span { @include text-xs; } + &.low { color: var(--alert-light); } } .price { justify-self: flex-end; diff --git a/app/frontend/src/stylesheets/modules/store/store.scss b/app/frontend/src/stylesheets/modules/store/store.scss index cf3d306f5..1c42e85d9 100644 --- a/app/frontend/src/stylesheets/modules/store/store.scss +++ b/app/frontend/src/stylesheets/modules/store/store.scss @@ -1,52 +1,180 @@ -.store { +.store, +.store-product { max-width: 1600px; @include grid-col(12); gap: 3.2rem; margin: 0 auto; padding-bottom: 6rem; - - //&-product-item { - // padding: 1rem 1.8rem; - // border: 1px solid var(--gray-soft-dark); - // border-radius: var(--border-radius); - // background-color: var(--gray-soft-lightest); - - // margin-right: 1.6rem; - - // .itemInfo-image { - // align-items: center; - - // img { - // width: 19.8rem; - // height: 14.8rem; - // object-fit: cover; - // border-radius: var(--border-radius); - // background-color: var(--gray-soft); - // } - // } - // .itemInfo-name { - // margin: 1rem 0; - // @include text-base; - // font-weight: 600; - // color: var(--gray-hard-darkest); - // } - - // .actions { - // display: flex; - // align-items: center; - // .manage { - // overflow: hidden; - // display: flex; - // border-radius: var(--border-radius-sm); - // button { - // @include btn; - // border-radius: 0; - // color: var(--gray-soft-lightest); - // &:hover { opacity: 0.75; } - // } - // .edit-btn {background: var(--gray-hard-darkest) } - // .delete-btn {background: var(--error) } - // } - // } - //} +} + +.store-product { + --status-color: var(--success); + &.low { --status-color: var(--alert-light); } + &.out-of-stock { --status-color: var(--alert); } + + padding-top: 4rem; + gap: 0 3.2rem; + align-items: flex-start; + + .ref { + grid-area: 1 / 1 / 2 / 9; + @include text-sm; + color: var(--gray-hard-lightest); + text-transform: uppercase; + } + .name { + grid-area: 2 / 1 / 3 / 9; + margin: 0.8rem 0 3.2rem; + @include title-lg; + color: var(--gray-hard-darkest) !important; + } + .gallery { + grid-area: 3 / 1 / 4 / 4; + .aspect-ratio { + position: relative; + width: 100%; + height: 0; + padding-bottom: 100%; + overflow: hidden; + border-radius: var(--border-radius-sm); + border: 1px solid var(--gray-soft-darkest); + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + cursor: pointer; + } + } + .thumbnails { + margin-top: 1.6rem; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.8rem; + .is-active { + border-color: transparent; + box-shadow: 0 0 0 2px var(--gray-hard-darkest); + } + } + } + .description { + grid-area: 3 / 4 / 4 / 9; + &-text { + padding-bottom: 4rem; + overflow: hidden; + @include editor; + transition: max-height 0.5s ease-in-out; + h3 { + @include text-base(600); + } + p { + @include text-sm; + color: var(--gray-hard-lightest); + } + } + &-toggle { + position: relative; + width: 100%; + height: 6rem; + display: flex; + justify-content: center; + align-items: flex-end; + background: linear-gradient(0deg, white 0%, transparent 100%); + border: none; + transform: translateY(-4rem); + &::before { + position: absolute; + bottom: 1.2rem; + left: 0; + content: ''; + width: 100%; + height: 1px; + background-color: var(--information); + z-index: -1; + } + span { + padding: 0 1.6rem; + color: var(--information); + background-color: var(--gray-soft-lightest); + } + } + &-document { + padding: 2.4rem; + background-color: var(--gray-soft-light); + p { @include text-sm(500); } + .list { + display: flex; + flex-wrap: wrap; + gap: 0.8rem 1.6rem; + a { + display: flex; + align-items: center; + svg { margin-right: 0.8rem; } + } + } + } + } + aside { + grid-area: 1 / -4 / 4 / -1; + position: sticky; + top: 4rem; + padding: 4rem; + background-color: var(--gray-soft-light); + border-radius: var(--border-radius-sm); + + .stock { + display: flex; + align-items: center; + @include text-sm; + color: var(--status-color); + &::before { + content: ""; + margin-right: 0.8rem; + width: 1rem; + height: 1rem; + background-color: var(--status-color); + border-radius: 50%; + } + } + .price { + p { + margin: 0; + display: flex; + @include title-xl; + sup { + margin: 0.8rem 0 0 0.8rem; + @include title-sm; + } + } + span { + @include text-sm; + } + } + .to-cart { + margin-top: 1.6rem; + padding-top: 3.2rem; + display: grid; + grid-template-areas: "minus input plus" + "btn btn btn"; + gap: 1.6rem; + border-top: 1px solid var(--gray-soft-dark); + .minus { + grid-area: minus; + color: var(--gray-hard-darkest); + } + .plus { + grid-area: plus; + color: var(--gray-hard-darkest); + } + input { + grid-area: input; + text-align: center; + } + .main-action-btn { + grid-area: btn; + justify-content: center; + } + } + } } diff --git a/app/frontend/src/stylesheets/variables/typography.scss b/app/frontend/src/stylesheets/variables/typography.scss index 029148467..49eea5746 100644 --- a/app/frontend/src/stylesheets/variables/typography.scss +++ b/app/frontend/src/stylesheets/variables/typography.scss @@ -71,7 +71,7 @@ h3 { @include text-lg(600); margin: 0 0 1rem; - color: var(--gray-hard-darkest); + color: var(--gray-hard-darkest) !important; } ul { padding-inline-start: 2.2rem; diff --git a/app/frontend/templates/products/show.html b/app/frontend/templates/products/show.html index ecee5f9c3..c02f3a3d8 100644 --- a/app/frontend/templates/products/show.html +++ b/app/frontend/templates/products/show.html @@ -19,6 +19,6 @@
-
+
diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index a8aec1645..e6e15f831 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -396,6 +396,11 @@ en: price_high: "Price: high to low" store_product: unexpected_error_occurred: "An unexpected error occurred. Please try again later." + show_more: "Display more" + show_less: "Display less" + documentation: "Documentation" + store_product_item: + unit: "unit" cart: my_cart: "My Cart" cart_button: From 193c21a58315f2fc595fc7d85568de478236c3b1 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Thu, 25 Aug 2022 08:52:17 +0200 Subject: [PATCH 076/361] payment of cart by stripe/payzen/local --- app/controllers/api/cart_controller.rb | 15 +++-- app/controllers/api/checkout_controller.rb | 17 +++++ app/controllers/concerns/api/order_concern.rb | 4 ++ app/frontend/src/javascript/api/cart.ts | 5 ++ app/frontend/src/javascript/api/checkout.ts | 21 ++++++ app/frontend/src/javascript/api/member.ts | 10 +++ .../javascript/components/cart/store-cart.tsx | 64 ++++++++++++++++-- .../payment/abstract-payment-modal.tsx | 37 +++++++---- .../components/payment/card-payment-modal.tsx | 10 ++- .../local-payment/local-payment-form.tsx | 12 +++- .../local-payment/local-payment-modal.tsx | 10 ++- .../components/payment/payzen/payzen-form.tsx | 18 +++-- .../payment/payzen/payzen-modal.tsx | 10 ++- .../payment/stripe/payment-modal.tsx | 20 ++++-- .../components/payment/stripe/stripe-form.tsx | 32 +++++++-- .../payment/stripe/stripe-modal.tsx | 10 ++- .../components/user/member-select.tsx | 66 +++++++++++++++++++ app/frontend/src/javascript/models/order.ts | 10 +++ app/models/order.rb | 3 + app/policies/cart_policy.rb | 4 ++ app/services/cart/set_customer_service.rb | 10 +++ app/services/checkout/payment_service.rb | 33 ++++++++++ app/services/payments/local_service.rb | 11 ++++ app/services/payments/payment_concern.rb | 28 ++++++++ app/services/payments/payzen_service.rb | 36 ++++++++++ app/services/payments/stripe_service.rb | 43 ++++++++++++ app/services/product_service.rb | 8 +++ app/services/wallet_service.rb | 7 +- app/views/api/orders/_order.json.jbuilder | 5 ++ config/routes.rb | 5 ++ ...220822081222_add_payment_state_to_order.rb | 5 ++ db/schema.rb | 3 +- lib/pay_zen/helper.rb | 14 ++++ 33 files changed, 526 insertions(+), 60 deletions(-) create mode 100644 app/frontend/src/javascript/api/checkout.ts create mode 100644 app/frontend/src/javascript/components/user/member-select.tsx create mode 100644 app/services/cart/set_customer_service.rb create mode 100644 app/services/checkout/payment_service.rb create mode 100644 app/services/payments/local_service.rb create mode 100644 app/services/payments/payment_concern.rb create mode 100644 app/services/payments/payzen_service.rb create mode 100644 app/services/payments/stripe_service.rb create mode 100644 db/migrate/20220822081222_add_payment_state_to_order.rb diff --git a/app/controllers/api/cart_controller.rb b/app/controllers/api/cart_controller.rb index f26d97469..8ef581988 100644 --- a/app/controllers/api/cart_controller.rb +++ b/app/controllers/api/cart_controller.rb @@ -10,7 +10,10 @@ class API::CartController < API::ApiController def create authorize :cart, :create? @order = Order.find_by(token: order_token) - @order = Order.find_by(statistic_profile_id: current_user.statistic_profile.id, state: 'cart') if @order.nil? && current_user&.member? + if @order.nil? && current_user&.member? + @order = Order.where(statistic_profile_id: current_user.statistic_profile.id, + state: 'cart').last + end if @order @order.update(statistic_profile_id: current_user.statistic_profile.id) if @order.statistic_profile_id.nil? && current_user&.member? @order.update(operator_id: current_user.id) if @order.operator_id.nil? && current_user&.privileged? @@ -37,13 +40,15 @@ class API::CartController < API::ApiController render 'api/orders/show' end + def set_customer + authorize @current_order, policy_class: CartPolicy + @order = Cart::SetCustomerService.new.call(@current_order, cart_params[:user_id]) + render 'api/orders/show' + end + private def orderable Product.find(cart_params[:orderable_id]) end - - def cart_params - params.permit(:order_token, :orderable_id, :quantity) - end end diff --git a/app/controllers/api/checkout_controller.rb b/app/controllers/api/checkout_controller.rb index 4c9425747..3aeb661d4 100644 --- a/app/controllers/api/checkout_controller.rb +++ b/app/controllers/api/checkout_controller.rb @@ -3,4 +3,21 @@ # API Controller for cart checkout class API::CheckoutController < API::ApiController include ::API::OrderConcern + before_action :authenticate_user! + before_action :current_order + before_action :ensure_order + + def payment + res = Checkout::PaymentService.new.payment(@current_order, current_user, params[:payment_id]) + render json: res + rescue StandardError => e + render json: e, status: :unprocessable_entity + end + + def confirm_payment + res = Checkout::PaymentService.new.confirm_payment(@current_order, current_user, params[:payment_id]) + render json: res + rescue StandardError => e + render json: e, status: :unprocessable_entity + end end diff --git a/app/controllers/concerns/api/order_concern.rb b/app/controllers/concerns/api/order_concern.rb index eb38d42d0..e1a190868 100644 --- a/app/controllers/concerns/api/order_concern.rb +++ b/app/controllers/concerns/api/order_concern.rb @@ -15,4 +15,8 @@ module API::OrderConcern def ensure_order raise ActiveRecord::RecordNotFound if @current_order.nil? end + + def cart_params + params.permit(:order_token, :orderable_id, :quantity, :user_id) + end end diff --git a/app/frontend/src/javascript/api/cart.ts b/app/frontend/src/javascript/api/cart.ts index 4601de322..e3f0ebd16 100644 --- a/app/frontend/src/javascript/api/cart.ts +++ b/app/frontend/src/javascript/api/cart.ts @@ -22,4 +22,9 @@ export default class CartAPI { const res: AxiosResponse = await apiClient.put('/api/cart/set_quantity', { order_token: order.token, orderable_id: orderableId, quantity }); return res?.data; } + + static async setCustomer (order: Order, userId: number): Promise { + const res: AxiosResponse = await apiClient.put('/api/cart/set_customer', { order_token: order.token, user_id: userId }); + return res?.data; + } } diff --git a/app/frontend/src/javascript/api/checkout.ts b/app/frontend/src/javascript/api/checkout.ts new file mode 100644 index 000000000..ed9c41bb8 --- /dev/null +++ b/app/frontend/src/javascript/api/checkout.ts @@ -0,0 +1,21 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { OrderPayment } from '../models/order'; + +export default class CheckoutAPI { + static async payment (token: string, paymentId?: string): Promise { + const res: AxiosResponse = await apiClient.post('/api/checkout/payment', { + order_token: token, + payment_id: paymentId + }); + return res?.data; + } + + static async confirmPayment (token: string, paymentId: string): Promise { + const res: AxiosResponse = await apiClient.post('/api/checkout/confirm_payment', { + order_token: token, + payment_id: paymentId + }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/member.ts b/app/frontend/src/javascript/api/member.ts index 0c3697c18..e22262a68 100644 --- a/app/frontend/src/javascript/api/member.ts +++ b/app/frontend/src/javascript/api/member.ts @@ -9,6 +9,16 @@ export default class MemberAPI { return res?.data; } + static async search (name: string): Promise> { + const res: AxiosResponse> = await apiClient.get(`/api/members/search/${name}`); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/members/${id}`); + return res?.data; + } + static async create (user: User): Promise { const data = serialize({ user }); if (user.profile_attributes?.user_avatar_attributes?.attachment_files[0]) { diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index d24adffc2..0a7fa7126 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; import { Loader } from '../base/loader'; @@ -8,12 +8,16 @@ import useCart from '../../hooks/use-cart'; import FormatLib from '../../lib/format'; import CartAPI from '../../api/cart'; import { User } from '../../models/user'; +import { PaymentModal } from '../payment/stripe/payment-modal'; +import { PaymentMethod } from '../../models/payment'; +import { Order } from '../../models/order'; +import { MemberSelect } from '../user/member-select'; declare const Application: IApplication; interface StoreCartProps { onError: (message: string) => void, - currentUser: User, + currentUser?: User, } /** @@ -23,6 +27,7 @@ const StoreCart: React.FC = ({ onError, currentUser }) => { const { t } = useTranslation('public'); const { cart, setCart, reloadCart } = useCart(); + const [paymentModal, setPaymentModal] = useState(false); useEffect(() => { if (currentUser) { @@ -58,7 +63,38 @@ const StoreCart: React.FC = ({ onError, currentUser }) => { * Checkout cart */ const checkout = () => { - console.log('checkout .....'); + setPaymentModal(true); + }; + + /** + * Open/closes the payment modal + */ + const togglePaymentModal = (): void => { + setPaymentModal(!paymentModal); + }; + + /** + * Open/closes the payment modal + */ + const handlePaymentSuccess = (data: Order): void => { + console.log(data); + setPaymentModal(false); + }; + + /** + * Change cart's customer by admin/manger + */ + const handleChangeMember = (userId: number): void => { + CartAPI.setCustomer(cart, userId).then(data => { + setCart(data); + }); + }; + + /** + * Check if the current operator has administrative rights or is a normal member + */ + const isPrivileged = (): boolean => { + return (currentUser?.role === 'admin' || currentUser?.role === 'manager'); }; return ( @@ -79,10 +115,24 @@ const StoreCart: React.FC = ({ onError, currentUser }) => {
))} - {cart &&

Totale: {FormatLib.price(cart.amount)}

} - - {t('app.public.store_cart.checkout')} - + {cart && cart.order_items_attributes.length > 0 &&

Totale: {FormatLib.price(cart.amount)}

} + {cart && isPrivileged() && } + {cart && + + {t('app.public.store_cart.checkout')} + + } + {cart && cart.order_items_attributes.length > 0 && cart.user &&
+ console.log('success')} /> +
}
); }; diff --git a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx index 2fc93aae3..898703471 100644 --- a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx @@ -18,16 +18,18 @@ import { GoogleTagManager } from '../../models/gtm'; import { ComputePriceResult } from '../../models/price'; import { Wallet } from '../../models/wallet'; import FormatLib from '../../lib/format'; +import { Order } from '../../models/order'; export interface GatewayFormProps { onSubmit: () => void, - onSuccess: (result: Invoice|PaymentSchedule) => void, + onSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, customer: User, operator: User, className?: string, paymentSchedule?: PaymentSchedule, cart?: ShoppingCart, + order?: Order, updateCart?: (cart: ShoppingCart) => void, formId: string, } @@ -35,9 +37,10 @@ export interface GatewayFormProps { interface AbstractPaymentModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, updateCart?: (cart: ShoppingCart) => void, currentUser: User, schedule?: PaymentSchedule, @@ -61,7 +64,7 @@ declare const GTM: GoogleTagManager; * This component must not be called directly but must be extended for each implemented payment gateway. * @see https://reactjs.org/docs/composition-vs-inheritance.html */ -export const AbstractPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => { +export const AbstractPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize, order }) => { // customer's wallet const [wallet, setWallet] = useState(null); // server-computed price with all details @@ -108,16 +111,25 @@ export const AbstractPaymentModal: React.FC = ({ isOp * - Refresh the remaining price */ useEffect(() => { - if (!cart) return; - WalletAPI.getByUser(cart.customer_id).then((wallet) => { - setWallet(wallet); - PriceAPI.compute(cart).then((res) => { - setPrice(res); - setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price)); + if (order && order?.user?.id) { + WalletAPI.getByUser(order.user.id).then((wallet) => { + setWallet(wallet); + const p = { price: order.amount, price_without_coupon: order.amount }; + setPrice(p); + setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(p.price)); setReady(true); }); - }); - }, [cart]); + } else if (cart && cart.customer_id) { + WalletAPI.getByUser(cart.customer_id).then((wallet) => { + setWallet(wallet); + PriceAPI.compute(cart).then((res) => { + setPrice(res); + setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price)); + setReady(true); + }); + }); + } + }, [cart, order]); /** * Check if there is currently an error to display @@ -157,7 +169,7 @@ export const AbstractPaymentModal: React.FC = ({ isOp /** * After sending the form with success, process the resulting payment method */ - const handleFormSuccess = async (result: Invoice|PaymentSchedule): Promise => { + const handleFormSuccess = async (result: Invoice|PaymentSchedule|Order): Promise => { setSubmitState(false); GTM.trackPurchase(result.id, result.total); afterSuccess(result); @@ -213,6 +225,7 @@ export const AbstractPaymentModal: React.FC = ({ isOp className={`gateway-form ${formClassName || ''}`} formId={formId} cart={cart} + order={order} updateCart={updateCart} customer={customer} paymentSchedule={schedule}> diff --git a/app/frontend/src/javascript/components/payment/card-payment-modal.tsx b/app/frontend/src/javascript/components/payment/card-payment-modal.tsx index 971f39278..b05240199 100644 --- a/app/frontend/src/javascript/components/payment/card-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/card-payment-modal.tsx @@ -11,15 +11,17 @@ import { Setting, SettingName } from '../../models/setting'; import { Invoice } from '../../models/invoice'; import SettingAPI from '../../api/setting'; import { useTranslation } from 'react-i18next'; +import { Order } from '../../models/order'; declare const Application: IApplication; interface CardPaymentModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, currentUser: User, schedule?: PaymentSchedule, customer: User @@ -29,7 +31,7 @@ interface CardPaymentModalProps { * This component open a modal dialog for the configured payment gateway, allowing the user to input his card data * to process an online payment. */ -const CardPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer }) => { +const CardPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer, order }) => { const { t } = useTranslation('shared'); const [gateway, setGateway] = useState(null); @@ -49,6 +51,7 @@ const CardPaymentModal: React.FC = ({ isOpen, toggleModal afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} currentUser={currentUser} schedule={schedule} customer={customer} />; @@ -63,6 +66,7 @@ const CardPaymentModal: React.FC = ({ isOpen, toggleModal afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} currentUser={currentUser} schedule={schedule} customer={customer} />; @@ -99,4 +103,4 @@ const CardPaymentModalWrapper: React.FC = (props) => { export { CardPaymentModalWrapper as CardPaymentModal }; -Application.Components.component('cardPaymentModal', react2angular(CardPaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer'])); +Application.Components.component('cardPaymentModal', react2angular(CardPaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer', 'order'])); diff --git a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx index c7878043f..9119a8b9d 100644 --- a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx +++ b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx @@ -9,6 +9,7 @@ import { SettingName } from '../../../models/setting'; import { CardPaymentModal } from '../card-payment-modal'; import { PaymentSchedule } from '../../../models/payment-schedule'; import { HtmlTranslate } from '../../base/html-translate'; +import CheckoutAPI from '../../../api/checkout'; const ALL_SCHEDULE_METHODS = ['card', 'check', 'transfer'] as const; type scheduleMethod = typeof ALL_SCHEDULE_METHODS[number]; @@ -24,7 +25,7 @@ type selectOption = { value: scheduleMethod, label: string }; * This is intended for use by privileged users. * The form validation button must be created elsewhere, using the attribute form={formId}. */ -export const LocalPaymentForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId }) => { +export const LocalPaymentForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId, order }) => { const { t } = useTranslation('admin'); const [method, setMethod] = useState('check'); @@ -86,8 +87,13 @@ export const LocalPaymentForm: React.FC = ({ onSubmit, onSucce } try { - const document = await LocalPaymentAPI.confirmPayment(cart); - onSuccess(document); + let res; + if (order) { + res = await CheckoutAPI.payment(order.token); + } else { + res = await LocalPaymentAPI.confirmPayment(cart); + } + onSuccess(res); } catch (e) { onError(e); } diff --git a/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx b/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx index 9d0ee2032..ad04b105b 100644 --- a/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx @@ -10,15 +10,17 @@ import { ModalSize } from '../../base/fab-modal'; import { Loader } from '../../base/loader'; import { react2angular } from 'react2angular'; import { IApplication } from '../../../models/application'; +import { Order } from '../../../models/order'; declare const Application: IApplication; interface LocalPaymentModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, updateCart: (cart: ShoppingCart) => void, currentUser: User, schedule?: PaymentSchedule, @@ -28,7 +30,7 @@ interface LocalPaymentModalProps { /** * This component enables a privileged user to confirm a local payments. */ -const LocalPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer }) => { +const LocalPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, order }) => { const { t } = useTranslation('admin'); /** @@ -54,7 +56,7 @@ const LocalPaymentModal: React.FC = ({ isOpen, toggleMod /** * Integrates the LocalPaymentForm into the parent AbstractPaymentModal */ - const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children }) => { + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children, order }) => { return ( = ({ isOpen, toggleMod className={className} formId={formId} cart={cart} + order={order} updateCart={updateCart} customer={customer} paymentSchedule={paymentSchedule}> @@ -81,6 +84,7 @@ const LocalPaymentModal: React.FC = ({ isOpen, toggleMod formClassName="local-payment-form" currentUser={currentUser} cart={cart} + order={order} updateCart={updateCart} customer={customer} afterSuccess={afterSuccess} diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx index 55278d051..fb53a57e7 100644 --- a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx @@ -12,6 +12,8 @@ import { } from '../../../models/payzen'; import { PaymentSchedule } from '../../../models/payment-schedule'; import { Invoice } from '../../../models/invoice'; +import CheckoutAPI from '../../../api/checkout'; +import { Order } from '../../../models/order'; // we use these two additional parameters to update the card, if provided interface PayzenFormProps extends GatewayFormProps { @@ -22,7 +24,7 @@ interface PayzenFormProps extends GatewayFormProps { * A form component to collect the credit card details and to create the payment method on Stripe. * The form validation button must be created elsewhere, using the attribute form={formId}. */ -export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId }) => { +export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId, order }) => { const PayZenKR = useRef(null); const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader'); @@ -44,7 +46,7 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE .catch(error => onError(error)); }).catch(error => onError(error)); }); - }, [cart, paymentSchedule, customer]); + }, [cart, paymentSchedule, customer, order]); /** * Ask the API to create the form token. @@ -55,6 +57,9 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE return await PayzenAPI.updateToken(paymentSchedule?.id); } else if (paymentSchedule) { return await PayzenAPI.chargeCreateToken(cart, customer); + } else if (order) { + const res = await CheckoutAPI.payment(order.token); + return res.payment as CreateTokenResponse; } else { return await PayzenAPI.chargeCreatePayment(cart, customer); } @@ -88,9 +93,12 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE /** * Confirm the payment, depending on the current type of payment (single shot or recurring) */ - const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise => { + const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise => { if (paymentSchedule) { return await PayzenAPI.confirmPaymentSchedule(event.clientAnswer.orderDetails.orderId, transaction.uuid, cart); + } else if (order) { + const res = await CheckoutAPI.confirmPayment(order.token, event.clientAnswer.orderDetails.orderId); + return res.order; } else { return await PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cart); } @@ -132,7 +140,9 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE try { const { result } = await PayZenKR.current.validateForm(); if (result === null) { - await PayzenAPI.checkCart(cart, customer); + if (!order) { + await PayzenAPI.checkCart(cart, customer); + } await PayZenKR.current.onSubmit(onPaid); await PayZenKR.current.onError(handleError); await PayZenKR.current.submit(); diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx index 0b6a70bf2..ea117b12f 100644 --- a/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx @@ -9,13 +9,15 @@ import payzenLogo from '../../../../../images/payzen-secure.png'; import mastercardLogo from '../../../../../images/mastercard.png'; import visaLogo from '../../../../../images/visa.png'; import { PayzenForm } from './payzen-form'; +import { Order } from '../../../models/order'; interface PayzenModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, currentUser: User, schedule?: PaymentSchedule, customer: User @@ -28,7 +30,7 @@ interface PayzenModalProps { * This component should not be called directly. Prefer using which can handle the configuration * of a different payment gateway. */ -export const PayzenModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => { +export const PayzenModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, order }) => { /** * Return the logos, shown in the modal footer. */ @@ -45,7 +47,7 @@ export const PayzenModal: React.FC = ({ isOpen, toggleModal, a /** * Integrates the PayzenForm into the parent PaymentModal */ - const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => { + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => { return ( = ({ isOpen, toggleModal, a operator={operator} formId={formId} cart={cart} + order={order} className={className} paymentSchedule={paymentSchedule}> {children} @@ -70,6 +73,7 @@ export const PayzenModal: React.FC = ({ isOpen, toggleModal, a className="payzen-modal" currentUser={currentUser} cart={cart} + order={order} customer={customer} afterSuccess={afterSuccess} onError={onError} diff --git a/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx b/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx index 0acd1b28d..05acba844 100644 --- a/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx @@ -11,13 +11,15 @@ import { LocalPaymentModal } from '../local-payment/local-payment-modal'; import { CardPaymentModal } from '../card-payment-modal'; import PriceAPI from '../../../api/price'; import { ComputePriceResult } from '../../../models/price'; +import { Order } from '../../../models/order'; interface PaymentModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, updateCart: (cart: ShoppingCart) => void, operator: User, schedule?: PaymentSchedule, @@ -27,7 +29,7 @@ interface PaymentModalProps { /** * This component is responsible for rendering the payment modal. */ -export const PaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer }) => { +export const PaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer, order }) => { // the user's wallet const [wallet, setWallet] = useState(null); // the price of the cart @@ -44,10 +46,14 @@ export const PaymentModal: React.FC = ({ isOpen, toggleModal, // refresh the price when the cart changes useEffect(() => { - PriceAPI.compute(cart).then(price => { - setPrice(price); - }); - }, [cart]); + if (order) { + setPrice({ price: order.amount, price_without_coupon: order.amount }); + } else { + PriceAPI.compute(cart).then(price => { + setPrice(price); + }); + } + }, [cart, order]); // refresh the remaining price when the cart price was computed and the wallet was retrieved useEffect(() => { @@ -73,6 +79,7 @@ export const PaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} updateCart={updateCart} currentUser={operator} customer={customer} @@ -86,6 +93,7 @@ export const PaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} currentUser={operator} customer={customer} schedule={schedule} diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx index 0343e4bb7..007ed388c 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx @@ -6,12 +6,14 @@ import { PaymentConfirmation } from '../../../models/payment'; import StripeAPI from '../../../api/stripe'; import { Invoice } from '../../../models/invoice'; import { PaymentSchedule } from '../../../models/payment-schedule'; +import CheckoutAPI from '../../../api/checkout'; +import { Order } from '../../../models/order'; /** * A form component to collect the credit card details and to create the payment method on Stripe. * The form validation button must be created elsewhere, using the attribute form={formId}. */ -export const StripeForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId }) => { +export const StripeForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId, order }) => { const { t } = useTranslation('shared'); const stripe = useStripe(); @@ -41,9 +43,19 @@ export const StripeForm: React.FC = ({ onSubmit, onSuccess, on } else { try { if (!paymentSchedule) { - // process the normal payment pipeline, including SCA validation - const res = await StripeAPI.confirmMethod(paymentMethod.id, cart); - await handleServerConfirmation(res); + if (order) { + const res = await CheckoutAPI.payment(order.token, paymentMethod.id); + if (res.payment) { + await handleServerConfirmation(res.payment as PaymentConfirmation); + } else { + res.order.total = res.order.amount; + await handleServerConfirmation(res.order); + } + } else { + // process the normal payment pipeline, including SCA validation + const res = await StripeAPI.confirmMethod(paymentMethod.id, cart); + await handleServerConfirmation(res); + } } else { const res = await StripeAPI.setupSubscription(paymentMethod.id, cart); await handleServerConfirmation(res, paymentMethod.id); @@ -61,7 +73,7 @@ export const StripeForm: React.FC = ({ onSubmit, onSuccess, on * @param paymentMethodId ID of the payment method, required only when confirming a payment schedule * @see app/controllers/api/stripe_controller.rb#confirm_payment */ - const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule, paymentMethodId?: string) => { + const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule|Order, paymentMethodId?: string) => { if ('error' in response) { if (response.error.statusText) { onError(response.error.statusText); @@ -78,8 +90,14 @@ export const StripeForm: React.FC = ({ onSubmit, onSuccess, on // The card action has been handled // The PaymentIntent can be confirmed again on the server try { - const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart); - await handleServerConfirmation(confirmation); + if (order) { + const confirmation = await CheckoutAPI.confirmPayment(order.token, result.paymentIntent.id); + confirmation.order.total = confirmation.order.amount; + await handleServerConfirmation(confirmation.order); + } else { + const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart); + await handleServerConfirmation(confirmation); + } } catch (e) { onError(e); } diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx index f4974615f..69fcb46cf 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx @@ -10,13 +10,15 @@ import stripeLogo from '../../../../../images/powered_by_stripe.png'; import mastercardLogo from '../../../../../images/mastercard.png'; import visaLogo from '../../../../../images/visa.png'; import { Invoice } from '../../../models/invoice'; +import { Order } from '../../../models/order'; interface StripeModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, currentUser: User, schedule?: PaymentSchedule, customer: User @@ -29,7 +31,7 @@ interface StripeModalProps { * This component should not be called directly. Prefer using which can handle the configuration * of a different payment gateway. */ -export const StripeModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => { +export const StripeModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, order }) => { /** * Return the logos, shown in the modal footer. */ @@ -47,7 +49,7 @@ export const StripeModal: React.FC = ({ isOpen, toggleModal, a /** * Integrates the StripeForm into the parent PaymentModal */ - const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => { + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => { return ( = ({ isOpen, toggleModal, a className={className} formId={formId} cart={cart} + order={order} customer={customer} paymentSchedule={paymentSchedule}> {children} @@ -74,6 +77,7 @@ export const StripeModal: React.FC = ({ isOpen, toggleModal, a formClassName="stripe-form" currentUser={currentUser} cart={cart} + order={order} customer={customer} afterSuccess={afterSuccess} onError={onError} diff --git a/app/frontend/src/javascript/components/user/member-select.tsx b/app/frontend/src/javascript/components/user/member-select.tsx new file mode 100644 index 000000000..0685c1147 --- /dev/null +++ b/app/frontend/src/javascript/components/user/member-select.tsx @@ -0,0 +1,66 @@ +import React, { useState, useEffect } from 'react'; +import AsyncSelect from 'react-select/async'; +import { useTranslation } from 'react-i18next'; +import MemberAPI from '../../api/member'; +import { User } from '../../models/user'; + +interface MemberSelectProps { + defaultUser?: User, + onSelected?: (userId: number) => void +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +type selectOption = { value: number, label: string }; + +/** + * This component renders the member select for manager. + */ +export const MemberSelect: React.FC = ({ defaultUser, onSelected }) => { + const { t } = useTranslation('public'); + const [value, setValue] = useState(); + + useEffect(() => { + if (defaultUser) { + setValue({ value: defaultUser.id, label: defaultUser.name }); + } + }, []); + + /** + * search members by name + */ + const loadMembers = async (inputValue: string): Promise> => { + if (!inputValue) { + return []; + } + const data = await MemberAPI.search(inputValue); + return data.map(u => { + return { value: u.id, label: u.name }; + }); + }; + + /** + * callback for handle select changed + */ + const onChange = (v: selectOption) => { + setValue(v); + onSelected(v.value); + }; + + return ( +
+
+

{t('app.public.member_select.select_a_member')}

+
+ +
+ ); +}; diff --git a/app/frontend/src/javascript/models/order.ts b/app/frontend/src/javascript/models/order.ts index f36393777..1f12355a0 100644 --- a/app/frontend/src/javascript/models/order.ts +++ b/app/frontend/src/javascript/models/order.ts @@ -1,13 +1,18 @@ import { TDateISO } from '../typings/date-iso'; +import { PaymentConfirmation } from './payment'; +import { CreateTokenResponse } from './payzen'; +import { User } from './user'; export interface Order { id: number, token: string, statistic_profile_id?: number, + user?: User, operator_id?: number, reference?: string, state?: string, amount?: number, + total?: number, created_at?: TDateISO, order_items_attributes: Array<{ id: number, @@ -19,3 +24,8 @@ export interface Order { is_offered: boolean }>, } + +export interface OrderPayment { + order: Order, + payment?: PaymentConfirmation|CreateTokenResponse +} diff --git a/app/models/order.rb b/app/models/order.rb index 2d848f325..ff7b72d7d 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -8,5 +8,8 @@ class Order < ApplicationRecord ALL_STATES = %w[cart].freeze enum state: ALL_STATES.zip(ALL_STATES).to_h + PAYMENT_STATES = %w[paid failed].freeze + enum payment_state: PAYMENT_STATES.zip(PAYMENT_STATES).to_h + validates :token, :state, presence: true end diff --git a/app/policies/cart_policy.rb b/app/policies/cart_policy.rb index 0aad87849..401622563 100644 --- a/app/policies/cart_policy.rb +++ b/app/policies/cart_policy.rb @@ -6,6 +6,10 @@ class CartPolicy < ApplicationPolicy true end + def set_customer? + user.privileged? + end + %w[add_item remove_item set_quantity].each do |action| define_method "#{action}?" do return user.privileged? || (record.statistic_profile_id == user.statistic_profile.id) if user diff --git a/app/services/cart/set_customer_service.rb b/app/services/cart/set_customer_service.rb new file mode 100644 index 000000000..2cdc21e15 --- /dev/null +++ b/app/services/cart/set_customer_service.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Provides methods for admin set customer to order +class Cart::SetCustomerService + def call(order, user_id) + user = User.find(user_id) + order.update(statistic_profile_id: user.statistic_profile.id) + order.reload + end +end diff --git a/app/services/checkout/payment_service.rb b/app/services/checkout/payment_service.rb new file mode 100644 index 000000000..ab7b6f954 --- /dev/null +++ b/app/services/checkout/payment_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Provides methods for pay cart +class Checkout::PaymentService + require 'pay_zen/helper' + require 'stripe/helper' + + def payment(order, operator, payment_id = '') + if operator.member? + if Stripe::Helper.enabled? + Payments::StripeService.new.payment(order, payment_id) + elsif PayZen::Helper.enabled? + Payments::PayzenService.new.payment(order) + else + raise Error('Bad gateway or online payment is disabled') + end + elsif operator.privileged? + Payments::LocalService.new.payment(order) + end + end + + def confirm_payment(order, operator, payment_id = '') + if operator.member? + if Stripe::Helper.enabled? + Payments::StripeService.new.confirm_payment(order, payment_id) + elsif PayZen::Helper.enabled? + Payments::PayzenService.new.confirm_payment(order, payment_id) + else + raise Error('Bad gateway or online payment is disabled') + end + end + end +end diff --git a/app/services/payments/local_service.rb b/app/services/payments/local_service.rb new file mode 100644 index 000000000..f2cc6f661 --- /dev/null +++ b/app/services/payments/local_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Provides methods for pay cart by Local +class Payments::LocalService + include Payments::PaymentConcern + + def payment(order) + o = payment_success(order) + { order: o } + end +end diff --git a/app/services/payments/payment_concern.rb b/app/services/payments/payment_concern.rb new file mode 100644 index 000000000..b2fdd53d8 --- /dev/null +++ b/app/services/payments/payment_concern.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Concern for Payment +module Payments::PaymentConcern + private + + def get_wallet_debit(user, total_amount) + wallet_amount = (user.wallet.amount * 100).to_i + wallet_amount >= total_amount ? total_amount : wallet_amount + end + + def debit_amount(order) + total = order.amount + wallet_debit = get_wallet_debit(order.statistic_profile.user, total) + total - wallet_debit + end + + def payment_success(order) + ActiveRecord::Base.transaction do + WalletService.debit_user_wallet(order, order.statistic_profile.user) + order.update(payment_state: 'paid') + order.order_items.each do |item| + ProductService.update_stock(item.orderable, 'external', 'sold', -item.quantity) + end + order.reload + end + end +end diff --git a/app/services/payments/payzen_service.rb b/app/services/payments/payzen_service.rb new file mode 100644 index 000000000..8a64cdbf0 --- /dev/null +++ b/app/services/payments/payzen_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Provides methods for pay cart by PayZen +class Payments::PayzenService + require 'pay_zen/helper' + require 'pay_zen/order' + require 'pay_zen/charge' + require 'pay_zen/service' + include Payments::PaymentConcern + + def payment(order) + amount = debit_amount(order) + + id = PayZen::Helper.generate_ref(order, order.statistic_profile.user.id) + + client = PayZen::Charge.new + result = client.create_payment(amount: PayZen::Service.new.payzen_amount(amount), + order_id: id, + customer: PayZen::Helper.generate_customer(order.statistic_profile.user.id, + order.statistic_profile.user.id, order)) + { order: order, payment: { formToken: result['answer']['formToken'], orderId: id } } + end + + def confirm_payment(order, payment_id) + client = PayZen::Order.new + payzen_order = client.get(payment_id, operation_type: 'DEBIT') + + if payzen_order['answer']['transactions'].any? { |transaction| transaction['status'] == 'PAID' } + o = payment_success(order) + { order: o } + else + order.update(payment_state: 'failed') + { order: order, payment_error: payzen_order['answer'] } + end + end +end diff --git a/app/services/payments/stripe_service.rb b/app/services/payments/stripe_service.rb new file mode 100644 index 000000000..76d307c51 --- /dev/null +++ b/app/services/payments/stripe_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Provides methods for pay cart by Stripe +class Payments::StripeService + require 'stripe/service' + include Payments::PaymentConcern + + def payment(order, payment_id) + amount = debit_amount(order) + # Create the PaymentIntent + intent = Stripe::PaymentIntent.create( + { + payment_method: payment_id, + amount: Stripe::Service.new.stripe_amount(amount), + currency: Setting.get('stripe_currency'), + confirmation_method: 'manual', + confirm: true, + customer: order.statistic_profile.user.payment_gateway_object.gateway_object_id + }, { api_key: Setting.get('stripe_secret_key') } + ) + + if intent&.status == 'succeeded' + o = payment_success(order) + return { order: o } + end + + if intent&.status == 'requires_action' && intent&.next_action&.type == 'use_stripe_sdk' + { order: order, payment: { requires_action: true, payment_intent_client_secret: intent.client_secret, + type: 'payment' } } + end + end + + def confirm_payment(order, payment_id) + intent = Stripe::PaymentIntent.confirm(payment_id, {}, { api_key: Setting.get('stripe_secret_key') }) + if intent&.status == 'succeeded' + o = payment_success(order) + { order: o } + else + order.update(payment_state: 'failed') + { order: order, payment_error: 'payment failed' } + end + end +end diff --git a/app/services/product_service.rb b/app/services/product_service.rb index 1bfcdde75..22365c2a1 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -22,4 +22,12 @@ class ProductService end nil end + + def self.update_stock(product, stock_type, reason, quantity) + remaining_stock = product.stock[stock_type] + quantity + product.product_stock_movements.create(stock_type: stock_type, reason: reason, quantity: quantity, remaining_stock: remaining_stock, + date: DateTime.current) + product.stock[stock_type] = remaining_stock + product.save + end end diff --git a/app/services/wallet_service.rb b/app/services/wallet_service.rb index 73f2b31ee..105a7a551 100644 --- a/app/services/wallet_service.rb +++ b/app/services/wallet_service.rb @@ -82,6 +82,8 @@ class WalletService def self.wallet_amount_debit(payment, user, coupon = nil) total = if payment.is_a? PaymentSchedule payment.payment_schedule_items.first.amount + elsif payment.is_a? Order + payment.amount else payment.total end @@ -106,10 +108,9 @@ class WalletService # wallet debit success raise DebitWalletError unless wallet_transaction - payment.set_wallet_transaction(wallet_amount, wallet_transaction.id) + payment.set_wallet_transaction(wallet_amount, wallet_transaction.id) unless payment.is_a? Order else - payment.set_wallet_transaction(wallet_amount, nil) + payment.set_wallet_transaction(wallet_amount, nil) unless payment.is_a? Order end end - end diff --git a/app/views/api/orders/_order.json.jbuilder b/app/views/api/orders/_order.json.jbuilder index c5883e467..84cc6d775 100644 --- a/app/views/api/orders/_order.json.jbuilder +++ b/app/views/api/orders/_order.json.jbuilder @@ -2,6 +2,11 @@ json.extract! order, :id, :token, :statistic_profile_id, :operator_id, :reference, :state, :created_at json.amount order.amount / 100.0 if order.amount.present? +json.user do + json.extract! order&.statistic_profile&.user, :id + json.role order&.statistic_profile&.user&.roles&.first&.name + json.name order&.statistic_profile&.user&.profile&.full_name +end json.order_items_attributes order.order_items do |item| json.id item.id diff --git a/config/routes.rb b/config/routes.rb index ef60a1515..6a190075f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -159,6 +159,11 @@ Rails.application.routes.draw do put 'add_item', on: :collection put 'remove_item', on: :collection put 'set_quantity', on: :collection + put 'set_customer', on: :collection + end + resources :checkout, only: %i[] do + post 'payment', on: :collection + post 'confirm_payment', on: :collection end # for admin diff --git a/db/migrate/20220822081222_add_payment_state_to_order.rb b/db/migrate/20220822081222_add_payment_state_to_order.rb new file mode 100644 index 000000000..f803a40ee --- /dev/null +++ b/db/migrate/20220822081222_add_payment_state_to_order.rb @@ -0,0 +1,5 @@ +class AddPaymentStateToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :payment_state, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 1025fe2e8..1b30b963c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_08_18_160821) do +ActiveRecord::Schema.define(version: 2022_08_22_081222) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -467,6 +467,7 @@ ActiveRecord::Schema.define(version: 2022_08_18_160821) do t.integer "amount" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "payment_state" t.index ["statistic_profile_id"], name: "index_orders_on_statistic_profile_id" end diff --git a/lib/pay_zen/helper.rb b/lib/pay_zen/helper.rb index ea98c972b..05a377ca2 100644 --- a/lib/pay_zen/helper.rb +++ b/lib/pay_zen/helper.rb @@ -59,10 +59,24 @@ class PayZen::Helper def generate_shopping_cart(cart_items, customer, operator) cart = if cart_items.is_a? ShoppingCart cart_items + elsif cart_items.is_a? Order + cart_items else cs = CartService.new(operator) cs.from_hash(cart_items) end + if cart.is_a? Order + return { + cartItemInfo: cart.order_items.map do |item| + { + productAmount: item.amount.to_i.to_s, + productLabel: item.orderable_id, + productQty: item.quantity.to_s, + productType: customer.organization? ? 'SERVICE_FOR_BUSINESS' : 'SERVICE_FOR_INDIVIDUAL' + } + end + } + end { cartItemInfo: cart.items.map do |item| { From 8a8ce607b7a91fed7facd19102526ea5bd611e14 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Thu, 25 Aug 2022 11:46:14 +0200 Subject: [PATCH 077/361] check product is valid and in stock before payment --- app/exceptions/cart/zero_price_error.rb | 5 +++++ app/models/order.rb | 4 ++-- app/services/checkout/payment_service.rb | 4 ++++ app/services/orders/in_stock_service.rb | 23 +++++++++++++++++++++++ app/services/orders/order_service.rb | 18 ++++++++++++++++++ app/services/payments/payment_concern.rb | 2 +- app/services/payments/payzen_service.rb | 2 ++ app/services/payments/stripe_service.rb | 3 +++ 8 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 app/exceptions/cart/zero_price_error.rb create mode 100644 app/services/orders/in_stock_service.rb create mode 100644 app/services/orders/order_service.rb diff --git a/app/exceptions/cart/zero_price_error.rb b/app/exceptions/cart/zero_price_error.rb new file mode 100644 index 000000000..7ac80e19f --- /dev/null +++ b/app/exceptions/cart/zero_price_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when order amount = 0 +class Cart::ZeroPriceError < StandardError +end diff --git a/app/models/order.rb b/app/models/order.rb index ff7b72d7d..b1a70cc5d 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -5,10 +5,10 @@ class Order < ApplicationRecord belongs_to :statistic_profile has_many :order_items, dependent: :destroy - ALL_STATES = %w[cart].freeze + ALL_STATES = %w[cart in_progress ready canceled return].freeze enum state: ALL_STATES.zip(ALL_STATES).to_h - PAYMENT_STATES = %w[paid failed].freeze + PAYMENT_STATES = %w[paid failed refunded].freeze enum payment_state: PAYMENT_STATES.zip(PAYMENT_STATES).to_h validates :token, :state, presence: true diff --git a/app/services/checkout/payment_service.rb b/app/services/checkout/payment_service.rb index ab7b6f954..2f4de1d55 100644 --- a/app/services/checkout/payment_service.rb +++ b/app/services/checkout/payment_service.rb @@ -6,6 +6,10 @@ class Checkout::PaymentService require 'stripe/helper' def payment(order, operator, payment_id = '') + raise Cart::OutStockError unless Orders::OrderService.new.in_stock?(order, 'external') + + raise Cart::InactiveProductError unless Orders::OrderService.new.all_products_is_active? + if operator.member? if Stripe::Helper.enabled? Payments::StripeService.new.payment(order, payment_id) diff --git a/app/services/orders/in_stock_service.rb b/app/services/orders/in_stock_service.rb new file mode 100644 index 000000000..06ad80697 --- /dev/null +++ b/app/services/orders/in_stock_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Provides methods for Check if the product is in stock +class Cart::SetQuantityService + def call(order) + + return order if quantity.to_i.zero? + + raise Cart::OutStockError if quantity.to_i > orderable.stock['external'] + + item = order.order_items.find_by(orderable: orderable) + + raise ActiveRecord::RecordNotFound if item.nil? + + different_quantity = quantity.to_i - item.quantity + order.amount += (orderable.amount * different_quantity) + ActiveRecord::Base.transaction do + item.update(quantity: quantity.to_i) + order.save + end + order.reload + end +end diff --git a/app/services/orders/order_service.rb b/app/services/orders/order_service.rb new file mode 100644 index 000000000..b4febade9 --- /dev/null +++ b/app/services/orders/order_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Provides methods for Order +class Orders::OrderService + def in_stock?(order, stock_type = 'external') + order.order_items.each do |item| + return false if item.orderable.stock[stock_type] < item.quantity + end + true + end + + def all_products_is_active?(order) + order.order_items.each do |item| + return false unless item.orderable.is_active + end + true + end +end diff --git a/app/services/payments/payment_concern.rb b/app/services/payments/payment_concern.rb index b2fdd53d8..dc19ac078 100644 --- a/app/services/payments/payment_concern.rb +++ b/app/services/payments/payment_concern.rb @@ -18,7 +18,7 @@ module Payments::PaymentConcern def payment_success(order) ActiveRecord::Base.transaction do WalletService.debit_user_wallet(order, order.statistic_profile.user) - order.update(payment_state: 'paid') + order.update(state: 'in_progress', payment_state: 'paid') order.order_items.each do |item| ProductService.update_stock(item.orderable, 'external', 'sold', -item.quantity) end diff --git a/app/services/payments/payzen_service.rb b/app/services/payments/payzen_service.rb index 8a64cdbf0..d45a59ae4 100644 --- a/app/services/payments/payzen_service.rb +++ b/app/services/payments/payzen_service.rb @@ -11,6 +11,8 @@ class Payments::PayzenService def payment(order) amount = debit_amount(order) + raise Cart::ZeroPriceError if amount.zero? + id = PayZen::Helper.generate_ref(order, order.statistic_profile.user.id) client = PayZen::Charge.new diff --git a/app/services/payments/stripe_service.rb b/app/services/payments/stripe_service.rb index 76d307c51..35c30a02d 100644 --- a/app/services/payments/stripe_service.rb +++ b/app/services/payments/stripe_service.rb @@ -7,6 +7,9 @@ class Payments::StripeService def payment(order, payment_id) amount = debit_amount(order) + + raise Cart::ZeroPriceError if amount.zero? + # Create the PaymentIntent intent = Stripe::PaymentIntent.create( { From d9687a007beea8bafcbc55d54bc3c9b9de706343 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Thu, 25 Aug 2022 16:23:00 +0200 Subject: [PATCH 078/361] fix various bugs for cart --- app/controllers/api/cart_controller.rb | 14 +++++++-- .../javascript/components/cart/store-cart.tsx | 30 ++++++++++--------- .../src/javascript/components/store/store.tsx | 8 +---- app/frontend/src/javascript/hooks/use-cart.ts | 9 +++++- app/views/api/orders/_order.json.jbuilder | 10 ++++--- config/locales/app.public.en.yml | 4 +++ config/locales/app.public.fr.yml | 4 +++ 7 files changed, 50 insertions(+), 29 deletions(-) diff --git a/app/controllers/api/cart_controller.rb b/app/controllers/api/cart_controller.rb index 8ef581988..444970b5b 100644 --- a/app/controllers/api/cart_controller.rb +++ b/app/controllers/api/cart_controller.rb @@ -9,10 +9,18 @@ class API::CartController < API::ApiController def create authorize :cart, :create? + p '-----------------' + p current_user @order = Order.find_by(token: order_token) - if @order.nil? && current_user&.member? - @order = Order.where(statistic_profile_id: current_user.statistic_profile.id, - state: 'cart').last + if @order.nil? + if current_user&.member? + @order = Order.where(statistic_profile_id: current_user.statistic_profile.id, + state: 'cart').last + end + if current_user&.privileged? + @order = Order.where(operator_id: current_user.id, + state: 'cart').last + end end if @order @order.update(statistic_profile_id: current_user.statistic_profile.id) if @order.statistic_profile_id.nil? && current_user&.member? diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index 0a7fa7126..b73a3579f 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; import { Loader } from '../base/loader'; @@ -26,15 +26,9 @@ interface StoreCartProps { const StoreCart: React.FC = ({ onError, currentUser }) => { const { t } = useTranslation('public'); - const { cart, setCart, reloadCart } = useCart(); + const { cart, setCart } = useCart(currentUser); const [paymentModal, setPaymentModal] = useState(false); - useEffect(() => { - if (currentUser) { - reloadCart(); - } - }, [currentUser]); - /** * Remove the product from cart */ @@ -97,8 +91,16 @@ const StoreCart: React.FC = ({ onError, currentUser }) => { return (currentUser?.role === 'admin' || currentUser?.role === 'manager'); }; + /** + * Check if the current cart is empty ? + */ + const cartIsEmpty = (): boolean => { + return cart && cart.order_items_attributes.length === 0; + }; + return (
+ {cart && cartIsEmpty() &&

{t('app.public.store_cart.cart_is_empty')}

} {cart && cart.order_items_attributes.map(item => (
{item.orderable_name}
@@ -115,23 +117,23 @@ const StoreCart: React.FC = ({ onError, currentUser }) => {
))} - {cart && cart.order_items_attributes.length > 0 &&

Totale: {FormatLib.price(cart.amount)}

} - {cart && isPrivileged() && } - {cart && + {cart && !cartIsEmpty() &&

Totale: {FormatLib.price(cart.amount)}

} + {cart && !cartIsEmpty() && isPrivileged() && } + {cart && !cartIsEmpty() && {t('app.public.store_cart.checkout')} } - {cart && cart.order_items_attributes.length > 0 && cart.user &&
+ {cart && !cartIsEmpty() && cart.user &&
console.log('success')} /> + updateCart={() => 'dont need update shopping cart'} />
}
); diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx index 5cf4a7e30..cce5e4726 100644 --- a/app/frontend/src/javascript/components/store/store.tsx +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -24,7 +24,7 @@ interface StoreProps { const Store: React.FC = ({ onError, currentUser }) => { const { t } = useTranslation('public'); - const { cart, setCart, reloadCart } = useCart(); + const { cart, setCart } = useCart(currentUser); const [products, setProducts] = useState>([]); @@ -40,12 +40,6 @@ const Store: React.FC = ({ onError, currentUser }) => { emitCustomEvent('CartUpdate', cart); }, [cart]); - useEffect(() => { - if (currentUser) { - reloadCart(); - } - }, [currentUser]); - return (
diff --git a/app/frontend/src/javascript/hooks/use-cart.ts b/app/frontend/src/javascript/hooks/use-cart.ts index bd7d8382f..a1617b667 100644 --- a/app/frontend/src/javascript/hooks/use-cart.ts +++ b/app/frontend/src/javascript/hooks/use-cart.ts @@ -2,8 +2,9 @@ import { useState, useEffect } from 'react'; import { Order } from '../models/order'; import CartAPI from '../api/cart'; import { getCartToken, setCartToken } from '../lib/cart-token'; +import { User } from '../models/user'; -export default function useCart () { +export default function useCart (user?: User) { const [cart, setCart] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -33,5 +34,11 @@ export default function useCart () { setLoading(false); }; + useEffect(() => { + if (user && cart && (!cart.statistic_profile_id || !cart.operator_id)) { + reloadCart(); + } + }, [user]); + return { loading, cart, error, setCart, reloadCart }; } diff --git a/app/views/api/orders/_order.json.jbuilder b/app/views/api/orders/_order.json.jbuilder index 84cc6d775..4118ce54c 100644 --- a/app/views/api/orders/_order.json.jbuilder +++ b/app/views/api/orders/_order.json.jbuilder @@ -2,10 +2,12 @@ json.extract! order, :id, :token, :statistic_profile_id, :operator_id, :reference, :state, :created_at json.amount order.amount / 100.0 if order.amount.present? -json.user do - json.extract! order&.statistic_profile&.user, :id - json.role order&.statistic_profile&.user&.roles&.first&.name - json.name order&.statistic_profile&.user&.profile&.full_name +if order&.statistic_profile&.user + json.user do + json.id order.statistic_profile.user.id + json.role order.statistic_profile.user.roles.first.name + json.name order.statistic_profile.user.profile.full_name + end end json.order_items_attributes order.order_items do |item| diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index ac9bf7ab1..d2a3117ad 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -391,6 +391,10 @@ en: my_cart: "My Cart" store_cart: checkout: "Checkout" + cart_is_empty: "Your cart is empty" + member_select: + select_a_member: "Select a member" + start_typing: "Start typing..." tour: conclusion: title: "Thank you for your attention" diff --git a/config/locales/app.public.fr.yml b/config/locales/app.public.fr.yml index e971c8e5f..0df4d3e45 100644 --- a/config/locales/app.public.fr.yml +++ b/config/locales/app.public.fr.yml @@ -391,6 +391,10 @@ fr: my_cart: "Mon Panier" store_cart: checkout: "Valider mon panier" + cart_is_empty: "Votre panier est vide" + member_select: + select_a_member: "Sélectionnez un membre" + start_typing: "Commencez à écrire..." tour: conclusion: title: "Merci de votre attention" From d602087710d40d41c5ffa178685dff70cd2b64bc Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 25 Aug 2022 17:08:43 +0200 Subject: [PATCH 079/361] Clients' store categories --- .../javascript/components/store/products.tsx | 19 +- .../components/store/store-product.tsx | 3 +- .../src/javascript/components/store/store.tsx | 174 +++++++++++------- .../stylesheets/modules/store/_utilities.scss | 22 +++ .../modules/store/store-filters.scss | 90 +++++---- .../modules/store/store-products-list.scss | 6 +- .../src/stylesheets/modules/store/store.scss | 29 ++- config/locales/app.public.en.yml | 33 ++-- 8 files changed, 236 insertions(+), 140 deletions(-) diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index c5793236a..92345d63c 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -108,7 +108,7 @@ const Products: React.FC = ({ onSuccess, onError }) => { }; /** - * Filter: toggle hidden products visibility + * Filter: toggle non-available products visibility */ const toggleVisible = (checked: boolean) => { setFilterVisible(checked); @@ -275,7 +275,7 @@ const Products: React.FC = ({ onSuccess, onError }) => { label={t('app.admin.store.products.filter_categories')} >
-
+
{productCategories.map(pc => (
diff --git a/app/services/product_service.rb b/app/services/product_service.rb index 7d0a398b5..52d069781 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -10,14 +10,17 @@ class ProductService state = filters[:disabled] == 'false' ? [nil, false] : true products = products.where(is_active: state) end - if filters[:page].present? - products = products.page(filters[:page]).per(PRODUCTS_PER_PAGE) - end + products = products.page(filters[:page]).per(PRODUCTS_PER_PAGE) if filters[:page].present? products end - def self.pages - Product.page(1).per(PRODUCTS_PER_PAGE).total_pages + def self.pages(filters) + products = Product.all + if filters[:is_active].present? + state = filters[:disabled] == 'false' ? [nil, false] : true + products = Product.where(is_active: state) + end + products.page(1).per(PRODUCTS_PER_PAGE).total_pages end # amount params multiplied by hundred From f705f71c4f2c147245b6e00e0c0dde7f5deb29f1 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 12 Sep 2022 15:55:41 +0200 Subject: [PATCH 134/361] (feat) display/remove ongoing stocks operations --- app/frontend/src/javascript/api/setting.ts | 4 +- .../components/store/product-stock-form.tsx | 72 ++++++++++++------- .../components/store/product-stock-modal.tsx | 15 ++-- app/frontend/src/javascript/lib/product.ts | 23 +++++- app/frontend/src/javascript/models/product.ts | 7 +- app/frontend/src/javascript/models/setting.ts | 48 ++++++------- .../modules/store/product-stock-form.scss | 25 +++++++ config/locales/app.admin.en.yml | 11 ++- yarn.lock | 18 ++--- 9 files changed, 146 insertions(+), 77 deletions(-) diff --git a/app/frontend/src/javascript/api/setting.ts b/app/frontend/src/javascript/api/setting.ts index 5e6e985ea..76cc112e1 100644 --- a/app/frontend/src/javascript/api/setting.ts +++ b/app/frontend/src/javascript/api/setting.ts @@ -8,7 +8,7 @@ export default class SettingAPI { return res?.data?.setting; } - static async query (names: Array): Promise> { + static async query (names: readonly SettingName[]): Promise> { const params = new URLSearchParams(); params.append('names', `['${names.join("','")}']`); @@ -32,7 +32,7 @@ export default class SettingAPI { return res?.data?.isPresent; } - private static toSettingsMap (names: Array, data: Record): Map { + private static toSettingsMap (names: readonly SettingName[], data: Record): Map { const map = new Map(); names.forEach(name => { map.set(name, data[name] || ''); diff --git a/app/frontend/src/javascript/components/store/product-stock-form.tsx b/app/frontend/src/javascript/components/store/product-stock-form.tsx index 50d629c4d..6574e877a 100644 --- a/app/frontend/src/javascript/components/store/product-stock-form.tsx +++ b/app/frontend/src/javascript/components/store/product-stock-form.tsx @@ -1,9 +1,8 @@ import React, { useEffect, useState } from 'react'; import Select from 'react-select'; -import { PencilSimple } from 'phosphor-react'; -import { ArrayPath, Path, useFieldArray, UseFormRegister } from 'react-hook-form'; -import { FieldValues } from 'react-hook-form/dist/types/fields'; -import { Control, FormState, UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { PencilSimple, X } from 'phosphor-react'; +import { useFieldArray, UseFormRegister } from 'react-hook-form'; +import { Control, FormState, UseFormSetValue } from 'react-hook-form/dist/types/form'; import { useTranslation } from 'react-i18next'; import { Product, ProductStockMovement, StockMovementReason, StockType } from '../../models/product'; import { HtmlTranslate } from '../base/html-translate'; @@ -15,15 +14,14 @@ import { ProductStockModal } from './product-stock-modal'; import { FabStateLabel } from '../base/fab-state-label'; import ProductAPI from '../../api/product'; import FormatLib from '../../lib/format'; -import { FieldPathValue } from 'react-hook-form/dist/types/path'; import ProductLib from '../../lib/product'; -interface ProductStockFormProps { +interface ProductStockFormProps { currentFormValues: Product, - register: UseFormRegister, - control: Control, - formState: FormState, - setValue: UseFormSetValue, + register: UseFormRegister, + control: Control, + formState: FormState, + setValue: UseFormSetValue, onSuccess: (product: Product) => void, onError: (message: string) => void, } @@ -33,7 +31,7 @@ const DEFAULT_LOW_STOCK_THRESHOLD = 30; /** * Form tab to manage a product's stock */ -export const ProductStockForm = ({ currentFormValues, register, control, formState, setValue, onError }: ProductStockFormProps) => { +export const ProductStockForm = ({ currentFormValues, register, control, formState, setValue, onError }: ProductStockFormProps) => { const { t } = useTranslation('admin'); const [activeThreshold, setActiveThreshold] = useState(currentFormValues.low_stock_threshold != null); @@ -41,7 +39,7 @@ export const ProductStockForm = (false); const [stockMovements, setStockMovements] = useState>([]); - const { fields, append } = useFieldArray({ control, name: 'product_stock_movements_attributes' as ArrayPath }); + const { fields, append, remove } = useFieldArray({ control, name: 'product_stock_movements_attributes' }); useEffect(() => { if (!currentFormValues?.id) return; @@ -103,8 +101,8 @@ export const ProductStockForm = { setActiveThreshold(checked); setValue( - 'low_stock_threshold' as Path, - (checked ? DEFAULT_LOW_STOCK_THRESHOLD : null) as UnpackNestedValue>> + 'low_stock_threshold', + (checked ? DEFAULT_LOW_STOCK_THRESHOLD : null) ); }; @@ -154,6 +152,35 @@ export const ProductStockForm = } className="is-black">Modifier
+ + {fields.length > 0 &&
+ {t('app.admin.store.product_stock_form.ongoing_operations')} + {t('app.admin.store.product_stock_form.save_reminder')} + {fields.map((newMovement, index) => ( +
+
+

{t(`app.admin.store.product_stock_form.type_${ProductLib.stockMovementType(newMovement.reason)}`)}

+
+
+ {t(`app.admin.store.product_stock_form.${newMovement.stock_type}`)} +

{ProductLib.absoluteStockMovement(newMovement.quantity, newMovement.reason)}

+
+
+ {t('app.admin.store.product_stock_form.reason')} +

{t(ProductLib.stockMovementReasonTrKey(newMovement.reason))}

+
+

remove(index)}> + {t('app.admin.store.product_stock_form.cancel')} + +

+ + + +
+ ))} +
} +
@@ -213,15 +240,15 @@ export const ProductStockForm = {currentFormValues.name}

{FormatLib.date(movement.date)}

- {movement.stock_type} -

{movement.quantity}

+ {t(`app.admin.store.product_stock_form.${movement.stock_type}`)} +

{ProductLib.absoluteStockMovement(movement.quantity, movement.reason)}

- {t('app.admin.store.product_stock_form.event_type')} -

{movement.reason}

+ {t('app.admin.store.product_stock_form.reason')} +

{t(ProductLib.stockMovementReasonTrKey(movement.reason))}

- {t('app.admin.store.product_stock_form.stock_level')} + {t('app.admin.store.product_stock_form.remaining_stock')}

{movement.remaining_stock}

@@ -231,13 +258,6 @@ export const ProductStockForm = - {fields.map((newMovement, index) => ( -
- - - -
- ))} ); }; diff --git a/app/frontend/src/javascript/components/store/product-stock-modal.tsx b/app/frontend/src/javascript/components/store/product-stock-modal.tsx index ab0457eb5..67b07e782 100644 --- a/app/frontend/src/javascript/components/store/product-stock-modal.tsx +++ b/app/frontend/src/javascript/components/store/product-stock-modal.tsx @@ -1,6 +1,12 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ProductStockMovement, StockMovementReason, StockType } from '../../models/product'; +import { + ProductStockMovement, + stockMovementInReasons, + stockMovementOutReasons, + StockMovementReason, + StockType +} from '../../models/product'; import { FormSelect } from '../form/form-select'; import { FormInput } from '../form/form-input'; import { FabButton } from '../base/fab-button'; @@ -57,12 +63,7 @@ export const ProductStockModal: React.FC = ({ onError, o * Creates sorting options to the react-select format */ const buildEventsOptions = (): Array => { - const options: Record> = { - in: ['inward_stock', 'returned', 'cancelled', 'inventory_fix'], - out: ['sold', 'missing', 'damaged'] - }; - - return options[movement].map(key => { + return (movement === 'in' ? stockMovementInReasons : stockMovementOutReasons).map(key => { return { value: key, label: t(ProductLib.stockMovementReasonTrKey(key)) }; }); }; diff --git a/app/frontend/src/javascript/lib/product.ts b/app/frontend/src/javascript/lib/product.ts index 4cccc73ea..f68eac54a 100644 --- a/app/frontend/src/javascript/lib/product.ts +++ b/app/frontend/src/javascript/lib/product.ts @@ -1,5 +1,5 @@ import { ProductCategory } from '../models/product-category'; -import { StockMovementReason } from '../models/product'; +import { stockMovementInReasons, stockMovementOutReasons, StockMovementReason } from '../models/product'; export default class ProductLib { /** @@ -26,4 +26,25 @@ export default class ProductLib { static stockMovementReasonTrKey = (reason: StockMovementReason): string => { return `app.admin.store.stock_movement_reason.${reason}`; }; + + /** + * Check if the given stock movement is of type 'in' or 'out' + */ + static stockMovementType = (reason: StockMovementReason): 'in' | 'out' => { + if ((stockMovementInReasons as readonly StockMovementReason[]).includes(reason)) return 'in'; + if ((stockMovementOutReasons as readonly StockMovementReason[]).includes(reason)) return 'out'; + + throw new Error(`Unexpected stock movement reason: ${reason}`); + }; + + /** + * Return the given quantity, prefixed by its addition operator (- or +) + */ + static absoluteStockMovement = (quantity: number, reason: StockMovementReason): string => { + if (ProductLib.stockMovementType(reason) === 'in') { + return `+${quantity}`; + } else { + return `-${quantity}`; + } + }; } diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index 3d7e69607..a3ead7cca 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -6,7 +6,12 @@ export interface ProductIndexFilter extends ApiFilter { } export type StockType = 'internal' | 'external' | 'all'; -export type StockMovementReason = 'inward_stock' | 'returned' | 'cancelled' | 'inventory_fix' | 'sold' | 'missing' | 'damaged'; + +export const stockMovementInReasons = ['inward_stock', 'returned', 'cancelled', 'inventory_fix'] as const; +export const stockMovementOutReasons = ['sold', 'missing', 'damaged'] as const; +export const stockMovementAllReasons = [...stockMovementInReasons, ...stockMovementOutReasons] as const; + +export type StockMovementReason = typeof stockMovementAllReasons[number]; export interface Stock { internal: number, diff --git a/app/frontend/src/javascript/models/setting.ts b/app/frontend/src/javascript/models/setting.ts index 144c53631..4576090a7 100644 --- a/app/frontend/src/javascript/models/setting.ts +++ b/app/frontend/src/javascript/models/setting.ts @@ -7,20 +7,20 @@ export const homePageSettings = [ 'home_content', 'home_css', 'upcoming_events_shown' -]; +] as const; export const privacyPolicySettings = [ 'privacy_draft', 'privacy_body', 'privacy_dpo' -]; +] as const; export const aboutPageSettings = [ 'about_title', 'about_body', 'about_contacts', 'link_name' -]; +] as const; export const socialNetworksSettings = [ 'facebook', @@ -36,7 +36,7 @@ export const socialNetworksSettings = [ 'pinterest', 'lastfm', 'flickr' -]; +] as const; export const messagesSettings = [ 'machine_explications_alert', @@ -45,7 +45,7 @@ export const messagesSettings = [ 'subscription_explications_alert', 'event_explications_alert', 'space_explications_alert' -]; +] as const; export const invoicesSettings = [ 'invoice_logo', @@ -64,7 +64,7 @@ export const invoicesSettings = [ 'invoice_legals', 'invoice_prefix', 'payment_schedule_prefix' -]; +] as const; export const bookingSettings = [ 'booking_window_start', @@ -81,17 +81,17 @@ export const bookingSettings = [ 'book_overlapping_slots', 'slot_duration', 'overlapping_categories' -]; +] as const; export const themeSettings = [ 'main_color', 'secondary_color' -]; +] as const; export const titleSettings = [ 'fablab_name', 'name_genre' -]; +] as const; export const accountingSettings = [ 'accounting_journal_code', @@ -115,7 +115,7 @@ export const accountingSettings = [ 'accounting_Event_label', 'accounting_Space_code', 'accounting_Space_label' -]; +] as const; export const modulesSettings = [ 'spaces_module', @@ -127,13 +127,13 @@ export const modulesSettings = [ 'online_payment_module', 'public_agenda_module', 'invoicing_module' -]; +] as const; export const stripeSettings = [ 'stripe_public_key', 'stripe_secret_key', 'stripe_currency' -]; +] as const; export const payzenSettings = [ 'payzen_username', @@ -142,13 +142,13 @@ export const payzenSettings = [ 'payzen_public_key', 'payzen_hmac', 'payzen_currency' -]; +] as const; export const openLabSettings = [ 'openlab_app_id', 'openlab_app_secret', 'openlab_default' -]; +] as const; export const accountSettings = [ 'phone_required', @@ -157,13 +157,13 @@ export const accountSettings = [ 'user_change_group', 'user_validation_required', 'user_validation_required_list' -]; +] as const; export const analyticsSettings = [ 'tracking_id', 'facebook_app_id', 'twitter_analytics' -]; +] as const; export const fabHubSettings = [ 'hub_last_version', @@ -171,43 +171,43 @@ export const fabHubSettings = [ 'fab_analytics', 'origin', 'uuid' -]; +] as const; export const projectsSettings = [ 'allowed_cad_extensions', 'allowed_cad_mime_types', 'disqus_shortname' -]; +] as const; export const prepaidPacksSettings = [ 'renew_pack_threshold', 'pack_only_for_subscription' -]; +] as const; export const registrationSettings = [ 'public_registrations', 'recaptcha_site_key', 'recaptcha_secret_key' -]; +] as const; export const adminSettings = [ 'feature_tour_display', 'show_username_in_admin_list' -]; +] as const; export const pricingSettings = [ 'extended_prices_in_same_day' -]; +] as const; export const poymentSettings = [ 'payment_gateway' -]; +] as const; export const displaySettings = [ 'machines_sort_by', 'events_in_calendar', 'email_from' -]; +] as const; export const allSettings = [ ...homePageSettings, diff --git a/app/frontend/src/stylesheets/modules/store/product-stock-form.scss b/app/frontend/src/stylesheets/modules/store/product-stock-form.scss index 03f5b8f73..e13f05245 100644 --- a/app/frontend/src/stylesheets/modules/store/product-stock-form.scss +++ b/app/frontend/src/stylesheets/modules/store/product-stock-form.scss @@ -1,6 +1,31 @@ .product-stock-form { h4 span { @include text-sm; } + .ongoing-stocks { + margin: 2.4rem 0; + .save-notice { + @include text-xs; + margin-left: 1rem; + color: var(--alert); + } + .unsaved-stock-movement { + background-color: var(--gray-soft-light); + border: 0; + padding: 1.2rem; + margin-top: 1rem; + + .cancel-action { + &:hover { + text-decoration: underline; + cursor: pointer; + } + svg { + margin-left: 1rem; + vertical-align: middle; + } + } + } + } .store-list { h4 { margin: 0; } } diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index bde6ca908..f4edde3b5 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1992,18 +1992,25 @@ en: product_stock_form: stock_up_to_date: "Stock up to date" date_time: "{DATE} - {TIME}" + ongoing_operations: "Ongoing stocks operations" + save_reminder: "Don't forget to save your operations" low_stock_threshold: "Define a low stock threshold" stock_threshold_toggle: "Activate stock threshold" stock_threshold_information: "Information
Define a low stock threshold and receive a notification when it's reached.
Above the threshold, the product is available in the store. When the threshold is reached, the product quantity is labeled as low." low_stock: "Low stock" threshold_level: "Minimum threshold level" threshold_alert: "Notify me when the threshold is reached" + events_history: "Events history" event_type: "Events:" + reason: "Reason" stocks: "Stocks:" internal: "Private stock" external: "Public stock" all: "All types" - stock_level: "Stock level" + remaining_stock: "Remaining stock" + type_in: "Add" + type_out: "Remove" + cancel: "Cancel this operation" product_stock_modal: modal_title: "Manage stock" internal: "Private stock" @@ -2018,7 +2025,7 @@ en: stock_movement_reason: inward_stock: "Inward stock" returned: "Returned by client" - canceled: "Canceled by client" + cancelled: "Canceled by client" inventory_fix: "Inventory fix" sold: "Sold" missing: "Missing in stock" diff --git a/yarn.lock b/yarn.lock index c96b1815c..281b9b244 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3299,20 +3299,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001313: - version "1.0.30001314" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001314.tgz#65c7f9fb7e4594fca0a333bec1d8939662377596" - integrity sha512-0zaSO+TnCHtHJIbpLroX7nsD+vYuOVjl3uzFbJO1wMVbuveJA0RK2WcQA9ZUIOiO0/ArMiMgHJLxfEZhQiC0kw== - -caniuse-lite@^1.0.30001219: - version "1.0.30001296" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001296.tgz" - integrity sha512-WfrtPEoNSoeATDlf4y3QvkwiELl9GyPLISV5GejTbbQRtQx4LhsXmc9IQ6XCL2d7UxCyEzToEZNMeqR79OUw8Q== - -caniuse-lite@^1.0.30001332: - version "1.0.30001335" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001335.tgz#899254a0b70579e5a957c32dced79f0727c61f2a" - integrity sha512-ddP1Tgm7z2iIxu6QTtbZUv6HJxSaV/PZeSrWFZtbY4JZ69tOeNhBCl3HyRQgeNZKE5AOn1kpV7fhljigy0Ty3w== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001313, caniuse-lite@^1.0.30001332: + version "1.0.30001397" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001397.tgz" + integrity sha512-SW9N2TbCdLf0eiNDRrrQXx2sOkaakNZbCjgNpPyMJJbiOrU5QzMIrXOVMRM1myBXTD5iTkdrtU/EguCrBocHlA== chalk@^2.0.0, chalk@^2.4.2: version "2.4.2" From dbe4570c3097393b3bfda3f9b822aa6dc5564a82 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 12 Sep 2022 19:44:13 +0200 Subject: [PATCH 135/361] (feat) client can show orders in dashbaord --- app/controllers/api/orders_controller.rb | 40 ++++++ app/frontend/src/javascript/api/order.ts | 16 +++ .../javascript/components/cart/store-cart.tsx | 62 ++------- .../components/store/order-item.tsx | 20 +-- .../components/store/orders-dashboard.tsx | 53 ++++++-- .../javascript/components/store/orders.tsx | 13 +- .../components/store/show-order.tsx | 123 +++++++++++------- .../src/javascript/controllers/orders.js | 2 +- app/frontend/src/javascript/lib/order.ts | 50 +++++++ app/frontend/src/javascript/models/order.ts | 17 +++ app/frontend/src/javascript/router.js | 22 ++-- app/frontend/templates/dashboard/orders.html | 4 +- app/frontend/templates/orders/show.html | 4 +- app/models/product.rb | 4 + app/policies/order_policy.rb | 16 +++ app/services/orders/order_service.rb | 26 ++++ app/views/api/orders/_order.json.jbuilder | 10 +- app/views/api/orders/index.json.jbuilder | 17 +++ app/views/api/orders/update.json.jbuilder | 3 + config/locales/app.public.fr.yml | 1 + config/locales/app.shared.en.yml | 11 +- config/routes.rb | 1 + 22 files changed, 368 insertions(+), 147 deletions(-) create mode 100644 app/controllers/api/orders_controller.rb create mode 100644 app/frontend/src/javascript/api/order.ts create mode 100644 app/frontend/src/javascript/lib/order.ts create mode 100644 app/policies/order_policy.rb create mode 100644 app/views/api/orders/index.json.jbuilder create mode 100644 app/views/api/orders/update.json.jbuilder diff --git a/app/controllers/api/orders_controller.rb b/app/controllers/api/orders_controller.rb new file mode 100644 index 000000000..096e0df82 --- /dev/null +++ b/app/controllers/api/orders_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# API Controller for resources of type Order +# Orders are used in store +class API::OrdersController < API::ApiController + before_action :authenticate_user! + before_action :set_order, only: %i[show update destroy] + + def index + @result = ::Orders::OrderService.list(params, current_user) + end + + def show; end + + def update + authorize @order + + if @order.update(order_parameters) + render status: :ok + else + render json: @order.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + authorize @order + @order.destroy + head :no_content + end + + private + + def set_order + @order = Order.find(params[:id]) + end + + def order_params + params.require(:order).permit(:state) + end +end diff --git a/app/frontend/src/javascript/api/order.ts b/app/frontend/src/javascript/api/order.ts new file mode 100644 index 000000000..7203899a4 --- /dev/null +++ b/app/frontend/src/javascript/api/order.ts @@ -0,0 +1,16 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { Order, OrderIndexFilter, OrderIndex } from '../models/order'; +import ApiLib from '../lib/api'; + +export default class ProductAPI { + static async index (filters?: OrderIndexFilter): Promise { + const res: AxiosResponse = await apiClient.get(`/api/orders${ApiLib.filtersToQuery(filters)}`); + return res?.data; + } + + static async get (id: number | string): Promise { + const res: AxiosResponse = await apiClient.get(`/api/orders/${id}`); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index f385d3821..d9677348e 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -14,9 +14,9 @@ import { Order } from '../../models/order'; import { MemberSelect } from '../user/member-select'; import { CouponInput } from '../coupon/coupon-input'; import { Coupon } from '../../models/coupon'; -import { computePriceWithCoupon } from '../../lib/coupon'; import noImage from '../../../../images/no_image.png'; import Switch from 'react-switch'; +import OrderLib from '../../lib/order'; declare const Application: IApplication; @@ -132,52 +132,6 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, } }; - /** - * Get the item total - */ - const itemAmount = (item): number => { - return item.quantity * Math.trunc(item.amount * 100) / 100; - }; - - /** - * return true if cart has offered item - */ - const hasOfferedItem = (): boolean => { - return cart.order_items_attributes - .filter(i => i.is_offered).length > 0; - }; - - /** - * Get the offered item total - */ - const offeredAmount = (): number => { - return cart.order_items_attributes - .filter(i => i.is_offered) - .map(i => Math.trunc(i.amount * 100) * i.quantity) - .reduce((acc, curr) => acc + curr, 0) / 100; - }; - - /** - * Get the total amount before offered amount - */ - const totalBeforeOfferedAmount = (): number => { - return (Math.trunc(cart.total * 100) + Math.trunc(offeredAmount() * 100)) / 100; - }; - - /** - * Get the coupon amount - */ - const couponAmount = (): number => { - return (Math.trunc(cart.total * 100) - Math.trunc(computePriceWithCoupon(cart.total, cart.coupon) * 100)) / 100.00; - }; - - /** - * Get the paid total amount - */ - const paidTotal = (): number => { - return computePriceWithCoupon(cart.total, cart.coupon); - }; - return (
@@ -185,7 +139,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, {cart && cart.order_items_attributes.map(item => (
- +
{t('app.public.store_cart.reference_short')} @@ -203,7 +157,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser,
{t('app.public.store_cart.total')} -

{FormatLib.price(itemAmount(item))}

+

{FormatLib.price(OrderLib.itemAmount(item))}

@@ -251,15 +205,15 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser,

{t('app.public.store_cart.checkout_header')}

{t('app.public.store_cart.checkout_products_COUNT', { COUNT: cart?.order_items_attributes.length })}
-

{t('app.public.store_cart.checkout_products_total')} {FormatLib.price(totalBeforeOfferedAmount())}

- {hasOfferedItem() && -

{t('app.public.store_cart.checkout_gift_total')} -{FormatLib.price(offeredAmount())}

+

{t('app.public.store_cart.checkout_products_total')} {FormatLib.price(OrderLib.totalBeforeOfferedAmount(cart))}

+ {OrderLib.hasOfferedItem(cart) && +

{t('app.public.store_cart.checkout_gift_total')} -{FormatLib.price(OrderLib.offeredAmount(cart))}

} {cart.coupon && -

{t('app.public.store_cart.checkout_coupon')} -{FormatLib.price(couponAmount())}

+

{t('app.public.store_cart.checkout_coupon')} -{FormatLib.price(OrderLib.couponAmount(cart))}

}
-

{t('app.public.store_cart.checkout_total')} {FormatLib.price(paidTotal())}

+

{t('app.public.store_cart.checkout_total')} {FormatLib.price(OrderLib.paidTotal(cart))}

{t('app.public.store_cart.checkout')} diff --git a/app/frontend/src/javascript/components/store/order-item.tsx b/app/frontend/src/javascript/components/store/order-item.tsx index 2781f39b8..4f27f0d39 100644 --- a/app/frontend/src/javascript/components/store/order-item.tsx +++ b/app/frontend/src/javascript/components/store/order-item.tsx @@ -19,10 +19,10 @@ export const OrderItem: React.FC = ({ order, currentUser }) => { /** * Go to order page */ - const showOrder = (ref: string) => { + const showOrder = (order: Order) => { isPrivileged() - ? window.location.href = `/#!/admin/store/o/${ref}` - : window.location.href = `/#!/store/o/${ref}`; + ? window.location.href = `/#!/admin/store/orders/${order.id}` + : window.location.href = `/#!/dashboard/orders/${order.id}`; }; /** @@ -41,7 +41,7 @@ export const OrderItem: React.FC = ({ order, currentUser }) => { return 'error'; case 'canceled': return 'canceled'; - case 'pending' || 'under_preparation': + case 'in_progress': return 'pending'; default: return 'normal'; @@ -50,24 +50,24 @@ export const OrderItem: React.FC = ({ order, currentUser }) => { return (
-

order.ref

+

{order.reference}

- - order.state + + {t(`app.shared.store.order_item.state.${order.state}`)}
{isPrivileged() &&
{t('app.shared.store.order_item.client')} -

order.user.name

+

{order?.user?.name || ''}

} -

order.created_at

+

{FormatLib.date(order.created_at)}

{t('app.shared.store.order_item.total')}

{FormatLib.price(order?.total)}

- showOrder('orderRef')} icon={} className="is-black" /> + showOrder(order)} icon={} className="is-black" />
); }; diff --git a/app/frontend/src/javascript/components/store/orders-dashboard.tsx b/app/frontend/src/javascript/components/store/orders-dashboard.tsx index 2460cd8f6..5dba6c459 100644 --- a/app/frontend/src/javascript/components/store/orders-dashboard.tsx +++ b/app/frontend/src/javascript/components/store/orders-dashboard.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; import { Loader } from '../base/loader'; @@ -6,10 +6,14 @@ import { IApplication } from '../../models/application'; import { StoreListHeader } from './store-list-header'; import { OrderItem } from './order-item'; import { FabPagination } from '../base/fab-pagination'; +import OrderAPI from '../../api/order'; +import { Order } from '../../models/order'; +import { User } from '../../models/user'; declare const Application: IApplication; interface OrdersDashboardProps { + currentUser: User, onError: (message: string) => void } /** @@ -21,15 +25,21 @@ type selectOption = { value: number, label: string }; /** * This component shows a list of all orders from the store for the current user */ -// TODO: delete next eslint disable -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export const OrdersDashboard: React.FC = ({ onError }) => { +export const OrdersDashboard: React.FC = ({ currentUser, onError }) => { const { t } = useTranslation('public'); - // TODO: delete next eslint disable - // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [orders, setOrders] = useState>([]); const [pageCount, setPageCount] = useState(0); const [currentPage, setCurrentPage] = useState(1); + const [totalCount, setTotalCount] = useState(1); + + useEffect(() => { + OrderAPI.index({}).then(res => { + setPageCount(res.total_pages); + setTotalCount(res.total_count); + setOrders(res.data); + }).catch(onError); + }, []); /** * Creates sorting options to the react-select format @@ -44,7 +54,26 @@ export const OrdersDashboard: React.FC = ({ onError }) => * Display option: sorting */ const handleSorting = (option: selectOption) => { - console.log('Sort option:', option); + OrderAPI.index({ page: 1, sort: option.value ? 'ASC' : 'DESC' }).then(res => { + setCurrentPage(1); + setOrders(res.data); + setPageCount(res.total_pages); + setTotalCount(res.total_count); + }).catch(onError); + }; + + /** + * Handle orders pagination + */ + const handlePagination = (page: number) => { + if (page !== currentPage) { + OrderAPI.index({ page }).then(res => { + setCurrentPage(page); + setOrders(res.data); + setPageCount(res.total_pages); + setTotalCount(res.total_count); + }).catch(onError); + } }; return ( @@ -55,15 +84,17 @@ export const OrdersDashboard: React.FC = ({ onError }) =>
- + {orders.map(order => ( + + ))}
{pageCount > 1 && - + }
@@ -78,4 +109,4 @@ const OrdersDashboardWrapper: React.FC = (props) => { ); }; -Application.Components.component('ordersDashboard', react2angular(OrdersDashboardWrapper, ['onError'])); +Application.Components.component('ordersDashboard', react2angular(OrdersDashboardWrapper, ['onError', 'currentUser'])); diff --git a/app/frontend/src/javascript/components/store/orders.tsx b/app/frontend/src/javascript/components/store/orders.tsx index 2402b226c..351d9f260 100644 --- a/app/frontend/src/javascript/components/store/orders.tsx +++ b/app/frontend/src/javascript/components/store/orders.tsx @@ -13,6 +13,8 @@ import { MemberSelect } from '../user/member-select'; import { User } from '../../models/user'; import { FormInput } from '../form/form-input'; import { TDateISODate } from '../../typings/date-iso'; +import OrderAPI from '../../api/order'; +import { Order } from '../../models/order'; declare const Application: IApplication; @@ -42,10 +44,17 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { const { register, getValues } = useForm(); + const [orders, setOrders] = useState>([]); const [filters, setFilters] = useImmer(initFilters); const [clearFilters, setClearFilters] = useState(false); const [accordion, setAccordion] = useState({}); + useEffect(() => { + OrderAPI.index({}).then(res => { + setOrders(res.data); + }).catch(onError); + }, []); + useEffect(() => { applyFilters(); setClearFilters(false); @@ -228,7 +237,9 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { onSelectOptionsChange={handleSorting} />
- + {orders.map(order => ( + + ))}
diff --git a/app/frontend/src/javascript/components/store/show-order.tsx b/app/frontend/src/javascript/components/store/show-order.tsx index b2aaac940..818668db6 100644 --- a/app/frontend/src/javascript/components/store/show-order.tsx +++ b/app/frontend/src/javascript/components/store/show-order.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { IApplication } from '../../models/application'; import { User } from '../../models/user'; @@ -7,11 +7,15 @@ import { Loader } from '../base/loader'; import noImage from '../../../../images/no_image.png'; import { FabStateLabel } from '../base/fab-state-label'; import Select from 'react-select'; +import OrderAPI from '../../api/order'; +import { Order } from '../../models/order'; +import FormatLib from '../../lib/format'; +import OrderLib from '../../lib/order'; declare const Application: IApplication; interface ShowOrderProps { - orderRef: string, + orderId: string, currentUser?: User, onError: (message: string) => void, onSuccess: (message: string) => void @@ -27,9 +31,17 @@ type selectOption = { value: number, label: string }; */ // TODO: delete next eslint disable // eslint-disable-next-line @typescript-eslint/no-unused-vars -export const ShowOrder: React.FC = ({ orderRef, currentUser, onError, onSuccess }) => { +export const ShowOrder: React.FC = ({ orderId, currentUser, onError, onSuccess }) => { const { t } = useTranslation('shared'); + const [order, setOrder] = useState(); + + useEffect(() => { + OrderAPI.get(orderId).then(data => { + setOrder(data); + }); + }, []); + /** * Check if the current operator has administrative rights or is a normal member */ @@ -42,14 +54,14 @@ export const ShowOrder: React.FC = ({ orderRef, currentUser, onE */ const buildOptions = (): Array => { return [ - { value: 0, label: t('app.shared.store.show_order.status.error') }, - { value: 1, label: t('app.shared.store.show_order.status.canceled') }, - { value: 2, label: t('app.shared.store.show_order.status.pending') }, - { value: 3, label: t('app.shared.store.show_order.status.under_preparation') }, - { value: 4, label: t('app.shared.store.show_order.status.paid') }, - { value: 5, label: t('app.shared.store.show_order.status.ready') }, - { value: 6, label: t('app.shared.store.show_order.status.collected') }, - { value: 7, label: t('app.shared.store.show_order.status.refunded') } + { value: 0, label: t('app.shared.store.show_order.state.error') }, + { value: 1, label: t('app.shared.store.show_order.state.canceled') }, + { value: 2, label: t('app.shared.store.show_order.state.pending') }, + { value: 3, label: t('app.shared.store.show_order.state.under_preparation') }, + { value: 4, label: t('app.shared.store.show_order.state.paid') }, + { value: 5, label: t('app.shared.store.show_order.state.ready') }, + { value: 6, label: t('app.shared.store.show_order.state.collected') }, + { value: 7, label: t('app.shared.store.show_order.state.refunded') } ]; }; @@ -81,17 +93,21 @@ export const ShowOrder: React.FC = ({ orderRef, currentUser, onE return 'error'; case 'canceled': return 'canceled'; - case 'pending' || 'under_preparation': + case 'in_progress': return 'pending'; default: return 'normal'; } }; + if (!order) { + return null; + } + return (
-

[order.ref]

+

[{order.reference}]

{isPrivileged() && - {Array.from({ length: 100 }, (_, i) => i + 1).map(v => ( + {Array.from({ length: 100 }, (_, i) => i + item.quantity_min).map(v => ( ))} diff --git a/app/frontend/src/javascript/models/order.ts b/app/frontend/src/javascript/models/order.ts index 6ff5ea089..856db6d4b 100644 --- a/app/frontend/src/javascript/models/order.ts +++ b/app/frontend/src/javascript/models/order.ts @@ -33,6 +33,7 @@ export interface Order { orderable_name: string, orderable_main_image_url?: string, quantity: number, + quantity_min: number, amount: number, is_offered: boolean }>, diff --git a/app/services/cart/add_item_service.rb b/app/services/cart/add_item_service.rb index aae1fd32f..c60cff026 100644 --- a/app/services/cart/add_item_service.rb +++ b/app/services/cart/add_item_service.rb @@ -7,6 +7,8 @@ class Cart::AddItemService raise Cart::InactiveProductError unless orderable.is_active + quantity = orderable.quantity_min > quantity.to_i ? orderable.quantity_min : quantity.to_i + raise Cart::OutStockError if quantity > orderable.stock['external'] item = order.order_items.find_by(orderable: orderable) diff --git a/app/services/cart/set_quantity_service.rb b/app/services/cart/set_quantity_service.rb index 400c622c1..45051b7a1 100644 --- a/app/services/cart/set_quantity_service.rb +++ b/app/services/cart/set_quantity_service.rb @@ -5,6 +5,8 @@ class Cart::SetQuantityService def call(order, orderable, quantity = nil) return order if quantity.to_i.zero? + quantity = orderable.quantity_min > quantity.to_i ? orderable.quantity_min : quantity.to_i + raise Cart::OutStockError if quantity.to_i > orderable.stock['external'] item = order.order_items.find_by(orderable: orderable) diff --git a/app/views/api/orders/_order.json.jbuilder b/app/views/api/orders/_order.json.jbuilder index 08ff74afe..1f9974b12 100644 --- a/app/views/api/orders/_order.json.jbuilder +++ b/app/views/api/orders/_order.json.jbuilder @@ -27,6 +27,7 @@ json.order_items_attributes order.order_items.order(created_at: :asc) do |item| json.orderable_name item.orderable.name json.orderable_main_image_url item.orderable.main_image&.attachment_url json.quantity item.quantity + json.quantity_min item.orderable.quantity_min json.amount item.amount / 100.0 json.is_offered item.is_offered end From b542cbab11600d367115f572c96fb97320a8baf7 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Tue, 13 Sep 2022 16:56:36 +0200 Subject: [PATCH 142/361] (bug) can't save product's price < 1 --- app/controllers/api/products_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index 925c8d829..8d32b6195 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -18,7 +18,7 @@ class API::ProductsController < API::ApiController def create authorize Product @product = Product.new(product_params) - @product.amount = ProductService.amount_multiplied_by_hundred(@product.amount) + @product.amount = ProductService.amount_multiplied_by_hundred(product_params[:amount]) if @product.save render status: :created else From c381c985d219f2f0bf998001d48cd2be72535213 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 13 Sep 2022 17:18:35 +0200 Subject: [PATCH 143/361] (wip) unsaved form alert --- .../components/form/unsaved-form-alert.tsx | 28 +++++++++++++++++++ .../components/store/edit-product.tsx | 16 +++++++---- .../components/store/new-product.tsx | 16 +++++++---- .../components/store/product-form.tsx | 6 +++- .../controllers/admin/store_products.js | 7 +++-- app/frontend/src/javascript/models/product.ts | 2 +- .../templates/admin/store/product_edit.html | 4 +-- .../templates/admin/store/product_new.html | 4 +-- 8 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 app/frontend/src/javascript/components/form/unsaved-form-alert.tsx diff --git a/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx b/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx new file mode 100644 index 000000000..007789eda --- /dev/null +++ b/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx @@ -0,0 +1,28 @@ +import React, { PropsWithChildren, useEffect } from 'react'; +import { UIRouter } from '@uirouter/angularjs'; +import { FormState } from 'react-hook-form/dist/types/form'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; + +interface UnsavedFormAlertProps { + uiRouter: UIRouter, + formState: FormState, +} + +/** + * Alert the user about unsaved changes in the given form, before leaving the current page + */ +export const UnsavedFormAlert = ({ uiRouter, formState, children }: PropsWithChildren>) => { + useEffect(() => { + const { transitionService, globals: { current } } = uiRouter; + transitionService.onBefore({ from: current.name }, () => { + const { isDirty } = formState; + console.log('transition start', isDirty); + }); + }, []); + + return ( +
+ {children} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/edit-product.tsx b/app/frontend/src/javascript/components/store/edit-product.tsx index 27ef76e1f..7e2dfabaa 100644 --- a/app/frontend/src/javascript/components/store/edit-product.tsx +++ b/app/frontend/src/javascript/components/store/edit-product.tsx @@ -6,6 +6,7 @@ import { IApplication } from '../../models/application'; import { ProductForm } from './product-form'; import { Product } from '../../models/product'; import ProductAPI from '../../api/product'; +import { UIRouter } from '@uirouter/angularjs'; declare const Application: IApplication; @@ -13,12 +14,13 @@ interface EditProductProps { productId: number, onSuccess: (message: string) => void, onError: (message: string) => void, + uiRouter: UIRouter } /** * This component show edit product form */ -const EditProduct: React.FC = ({ productId, onSuccess, onError }) => { +const EditProduct: React.FC = ({ productId, onSuccess, onError, uiRouter }) => { const { t } = useTranslation('admin'); const [product, setProduct] = useState(); @@ -40,19 +42,23 @@ const EditProduct: React.FC = ({ productId, onSuccess, onError if (product) { return (
- +
); } return null; }; -const EditProductWrapper: React.FC = ({ productId, onSuccess, onError }) => { +const EditProductWrapper: React.FC = (props) => { return ( - + ); }; -Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError'])); +Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError', 'uiRouter'])); diff --git a/app/frontend/src/javascript/components/store/new-product.tsx b/app/frontend/src/javascript/components/store/new-product.tsx index 5747e50ed..17b5711ec 100644 --- a/app/frontend/src/javascript/components/store/new-product.tsx +++ b/app/frontend/src/javascript/components/store/new-product.tsx @@ -4,18 +4,20 @@ import { react2angular } from 'react2angular'; import { Loader } from '../base/loader'; import { IApplication } from '../../models/application'; import { ProductForm } from './product-form'; +import { UIRouter } from '@uirouter/angularjs'; declare const Application: IApplication; interface NewProductProps { onSuccess: (message: string) => void, onError: (message: string) => void, + uiRouter: UIRouter, } /** * This component show new product form */ -const NewProduct: React.FC = ({ onSuccess, onError }) => { +const NewProduct: React.FC = ({ onSuccess, onError, uiRouter }) => { const { t } = useTranslation('admin'); const product = { @@ -46,17 +48,21 @@ const NewProduct: React.FC = ({ onSuccess, onError }) => { return (
- +
); }; -const NewProductWrapper: React.FC = ({ onSuccess, onError }) => { +const NewProductWrapper: React.FC = (props) => { return ( - + ); }; -Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError'])); +Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError', 'uiRouter'])); diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index ea7402b09..e0f8cf4e1 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -20,12 +20,15 @@ import ProductAPI from '../../api/product'; import { Plus } from 'phosphor-react'; import { ProductStockForm } from './product-stock-form'; import ProductLib from '../../lib/product'; +import { UnsavedFormAlert } from '../form/unsaved-form-alert'; +import { UIRouter } from '@uirouter/angularjs'; interface ProductFormProps { product: Product, title: string, onSuccess: (product: Product) => void, onError: (message: string) => void, + uiRouter: UIRouter } /** @@ -42,7 +45,7 @@ type checklistOption = { value: number, label: string }; /** * Form component to create or update a product */ -export const ProductForm: React.FC = ({ product, title, onSuccess, onError }) => { +export const ProductForm: React.FC = ({ product, title, onSuccess, onError, uiRouter }) => { const { t } = useTranslation('admin'); const { handleSubmit, register, control, formState, setValue, reset } = useForm({ defaultValues: { ...product } }); @@ -224,6 +227,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc {t('app.admin.store.product_form.save')}
+

setStockTab(false)}>{t('app.admin.store.product_form.product_parameters')}

diff --git a/app/frontend/src/javascript/controllers/admin/store_products.js b/app/frontend/src/javascript/controllers/admin/store_products.js index d9a066f8c..50c05329c 100644 --- a/app/frontend/src/javascript/controllers/admin/store_products.js +++ b/app/frontend/src/javascript/controllers/admin/store_products.js @@ -4,11 +4,14 @@ */ 'use strict'; -Application.Controllers.controller('AdminStoreProductController', ['$scope', 'CSRF', 'growl', '$state', '$transition$', - function ($scope, CSRF, growl, $state, $transition$) { +Application.Controllers.controller('AdminStoreProductController', ['$scope', 'CSRF', 'growl', '$state', '$transition$', '$uiRouter', + function ($scope, CSRF, growl, $state, $transition$, $uiRouter) { /* PUBLIC SCOPE */ $scope.productId = $transition$.params().id; + // the following item is used by the UnsavedFormAlert component to detect a page change + $scope.uiRouter = $uiRouter; + /** * Callback triggered in case of error */ diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index 2315f1961..99ef37dfc 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -60,5 +60,5 @@ export interface Product { _destroy?: boolean, is_main?: boolean }>, - product_stock_movements_attributes: Array, + product_stock_movements_attributes?: Array, } diff --git a/app/frontend/templates/admin/store/product_edit.html b/app/frontend/templates/admin/store/product_edit.html index 02e9df374..0f251220d 100644 --- a/app/frontend/templates/admin/store/product_edit.html +++ b/app/frontend/templates/admin/store/product_edit.html @@ -15,5 +15,5 @@ {{ 'app.admin.store.back_products_list' }}
- - \ No newline at end of file + + diff --git a/app/frontend/templates/admin/store/product_new.html b/app/frontend/templates/admin/store/product_new.html index 1af8e51f6..926660d87 100644 --- a/app/frontend/templates/admin/store/product_new.html +++ b/app/frontend/templates/admin/store/product_new.html @@ -15,5 +15,5 @@ {{ 'app.admin.store.back_products_list' }}
- - \ No newline at end of file + + From 522b559ced441060dc6406af69fe7c440209a668 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Tue, 13 Sep 2022 18:43:37 +0200 Subject: [PATCH 144/361] (feat) add payment status to order --- .../components/store/order-item.tsx | 21 ++------------ .../components/store/show-order.tsx | 20 ++----------- app/frontend/src/javascript/lib/order.ts | 29 +++++++++++++++++++ app/models/order.rb | 2 +- app/services/payments/payment_concern.rb | 2 +- app/views/api/orders/_order.json.jbuilder | 2 +- app/views/api/orders/index.json.jbuilder | 2 +- config/locales/app.shared.en.yml | 12 ++++---- 8 files changed, 45 insertions(+), 45 deletions(-) diff --git a/app/frontend/src/javascript/components/store/order-item.tsx b/app/frontend/src/javascript/components/store/order-item.tsx index 438144091..513d6e868 100644 --- a/app/frontend/src/javascript/components/store/order-item.tsx +++ b/app/frontend/src/javascript/components/store/order-item.tsx @@ -5,6 +5,7 @@ import FormatLib from '../../lib/format'; import { FabButton } from '../base/fab-button'; import { User } from '../../models/user'; import { FabStateLabel } from '../base/fab-state-label'; +import OrderLib from '../../lib/order'; interface OrderItemProps { order?: Order, @@ -32,28 +33,12 @@ export const OrderItem: React.FC = ({ order, currentUser }) => { return (currentUser?.role === 'admin' || currentUser?.role === 'manager'); }; - /** - * Returns a className according to the status - */ - const statusColor = (status: string) => { - switch (status) { - case 'error': - return 'error'; - case 'canceled': - return 'canceled'; - case 'in_progress': - return 'pending'; - default: - return 'normal'; - } - }; - return (

{order.reference}

- - {t(`app.shared.store.order_item.state.${order.state}`)} + + {t(`app.shared.store.order_item.state.${OrderLib.statusText(order)}`)}
{isPrivileged() && diff --git a/app/frontend/src/javascript/components/store/show-order.tsx b/app/frontend/src/javascript/components/store/show-order.tsx index e9d1f094e..9dea12ab5 100644 --- a/app/frontend/src/javascript/components/store/show-order.tsx +++ b/app/frontend/src/javascript/components/store/show-order.tsx @@ -84,22 +84,6 @@ export const ShowOrder: React.FC = ({ orderId, currentUser, onEr }) }; - /** - * Returns a className according to the status - */ - const statusColor = (status: string) => { - switch (status) { - case 'error': - return 'error'; - case 'canceled': - return 'canceled'; - case 'in_progress': - return 'pending'; - default: - return 'normal'; - } - }; - /** * Returns order's payment info */ @@ -174,8 +158,8 @@ export const ShowOrder: React.FC = ({ orderId, currentUser, onEr {t('app.shared.store.show_order.last_update')}

{FormatLib.date(order.updated_at)}

- - {t(`app.shared.store.show_order.state.${order.state}`)} + + {t(`app.shared.store.show_order.state.${OrderLib.statusText(order)}`)}
diff --git a/app/frontend/src/javascript/lib/order.ts b/app/frontend/src/javascript/lib/order.ts index 1c376a8a1..af2447e02 100644 --- a/app/frontend/src/javascript/lib/order.ts +++ b/app/frontend/src/javascript/lib/order.ts @@ -47,4 +47,33 @@ export default class OrderLib { static paidTotal = (order: Order): number => { return computePriceWithCoupon(order.total, order.coupon); }; + + /** + * Returns a className according to the status + */ + static statusColor = (order: Order) => { + switch (order.state) { + case 'payment': + if (order.payment_state === 'failed') { + return 'error'; + } + return 'normal'; + case 'canceled': + return 'canceled'; + case 'in_progress': + return 'pending'; + default: + return 'normal'; + } + }; + + /** + * Returns a status text according to the status + */ + static statusText = (order: Order) => { + if (order.state === 'payment') { + return `payment_${order.payment_state}`; + } + return order.state; + }; } diff --git a/app/models/order.rb b/app/models/order.rb index be171a6b6..8bdc995c4 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -9,7 +9,7 @@ class Order < PaymentDocument has_many :order_items, dependent: :destroy has_one :payment_gateway_object, as: :item - ALL_STATES = %w[cart in_progress ready canceled return].freeze + ALL_STATES = %w[cart payment in_progress ready canceled return].freeze enum state: ALL_STATES.zip(ALL_STATES).to_h PAYMENT_STATES = %w[paid failed refunded].freeze diff --git a/app/services/payments/payment_concern.rb b/app/services/payments/payment_concern.rb index 64e9d1840..3bcf3afab 100644 --- a/app/services/payments/payment_concern.rb +++ b/app/services/payments/payment_concern.rb @@ -27,7 +27,7 @@ module Payments::PaymentConcern else payment_method end - order.state = 'in_progress' + order.state = 'payment' order.payment_state = 'paid' if payment_id && payment_type order.payment_gateway_object = PaymentGatewayObject.new(gateway_object_id: payment_id, gateway_object_type: payment_type) diff --git a/app/views/api/orders/_order.json.jbuilder b/app/views/api/orders/_order.json.jbuilder index 1f9974b12..bad0aab5e 100644 --- a/app/views/api/orders/_order.json.jbuilder +++ b/app/views/api/orders/_order.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true json.extract! order, :id, :token, :statistic_profile_id, :operator_profile_id, :reference, :state, :created_at, :updated_at, :invoice_id, - :payment_method + :payment_method, :payment_state json.total order.total / 100.0 if order.total.present? json.payment_date order.invoice.created_at if order.invoice_id.present? json.wallet_amount order.wallet_amount / 100.0 if order.wallet_amount.present? diff --git a/app/views/api/orders/index.json.jbuilder b/app/views/api/orders/index.json.jbuilder index bd4484f5a..da28a45f6 100644 --- a/app/views/api/orders/index.json.jbuilder +++ b/app/views/api/orders/index.json.jbuilder @@ -5,7 +5,7 @@ json.total_pages @result[:total_pages] json.page_size @result[:page_size] json.total_count @result[:total_count] json.data @result[:data] do |order| - json.extract! order, :id, :statistic_profile_id, :reference, :state, :created_at + json.extract! order, :id, :statistic_profile_id, :reference, :state, :created_at, :payment_state, :updated_at json.total order.total / 100.0 if order.total.present? json.paid_total order.paid_total / 100.0 if order.paid_total.present? if order&.statistic_profile&.user diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 6e9a157c1..93ec1473f 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -567,6 +567,11 @@ en: state: cart: 'Cart' in_progress: 'In progress' + payment_paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" show_order: back_to_list: "Back to list" see_invoice: "See invoice" @@ -587,13 +592,10 @@ en: state: cart: 'Cart' in_progress: 'In progress' - error: "Payment error" + payment_paid: "Paid" + payment_failed: "Payment error" canceled: "Canceled" - pending: "Pending payment" - under_preparation: "Under preparation" - paid: "Paid" ready: "Ready" - collected: "Collected" refunded: "Refunded" payment: by_wallet: "by wallet" From f130ba46c10e0af45aede63fe75065ee3a6136e8 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Tue, 13 Sep 2022 18:55:08 +0200 Subject: [PATCH 145/361] (feat) show product sku in cart and order detail --- app/frontend/src/javascript/components/cart/store-cart.tsx | 2 +- app/frontend/src/javascript/components/store/show-order.tsx | 2 +- app/frontend/src/javascript/models/order.ts | 1 + app/views/api/orders/_order.json.jbuilder | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index af6744a99..644251959 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -142,7 +142,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser,
- {t('app.public.store_cart.reference_short')} + {t('app.public.store_cart.reference_short')} {item.orderable_ref || ''}

{item.orderable_name}

diff --git a/app/frontend/src/javascript/components/store/show-order.tsx b/app/frontend/src/javascript/components/store/show-order.tsx index 9dea12ab5..eff12c5e2 100644 --- a/app/frontend/src/javascript/components/store/show-order.tsx +++ b/app/frontend/src/javascript/components/store/show-order.tsx @@ -173,7 +173,7 @@ export const ShowOrder: React.FC = ({ orderId, currentUser, onEr
- {t('app.shared.store.show_order.reference_short')} + {t('app.shared.store.show_order.reference_short')} {item.orderable_ref || ''}

{item.orderable_name}

diff --git a/app/frontend/src/javascript/models/order.ts b/app/frontend/src/javascript/models/order.ts index 856db6d4b..1ab6d28a0 100644 --- a/app/frontend/src/javascript/models/order.ts +++ b/app/frontend/src/javascript/models/order.ts @@ -31,6 +31,7 @@ export interface Order { orderable_type: string, orderable_id: number, orderable_name: string, + orderable_ref?: string, orderable_main_image_url?: string, quantity: number, quantity_min: number, diff --git a/app/views/api/orders/_order.json.jbuilder b/app/views/api/orders/_order.json.jbuilder index bad0aab5e..e75303dfd 100644 --- a/app/views/api/orders/_order.json.jbuilder +++ b/app/views/api/orders/_order.json.jbuilder @@ -25,6 +25,7 @@ json.order_items_attributes order.order_items.order(created_at: :asc) do |item| json.orderable_type item.orderable_type json.orderable_id item.orderable_id json.orderable_name item.orderable.name + json.orderable_ref item.orderable.sku json.orderable_main_image_url item.orderable.main_image&.attachment_url json.quantity item.quantity json.quantity_min item.orderable.quantity_min From 32b19ed4f7d5d294cfca68bc4aab4dffe4f2d1d5 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Tue, 13 Sep 2022 19:47:19 +0200 Subject: [PATCH 146/361] (feat) manage orders in admin dashbaord --- app/frontend/src/javascript/components/store/order-item.tsx | 2 +- app/frontend/src/javascript/components/store/show-order.tsx | 2 +- app/frontend/src/javascript/controllers/admin/orders.js | 2 +- app/frontend/templates/admin/orders/show.html | 4 ++-- app/services/orders/order_service.rb | 2 ++ 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/frontend/src/javascript/components/store/order-item.tsx b/app/frontend/src/javascript/components/store/order-item.tsx index 513d6e868..b1afd5bbf 100644 --- a/app/frontend/src/javascript/components/store/order-item.tsx +++ b/app/frontend/src/javascript/components/store/order-item.tsx @@ -50,7 +50,7 @@ export const OrderItem: React.FC = ({ order, currentUser }) => {

{FormatLib.date(order.created_at)}

{t('app.shared.store.order_item.total')} -

{FormatLib.price(order?.paid_total)}

+

{FormatLib.price(order.state === 'cart' ? order.total : order.paid_total)}

showOrder(order)} icon={} className="is-black" />
diff --git a/app/frontend/src/javascript/components/store/show-order.tsx b/app/frontend/src/javascript/components/store/show-order.tsx index eff12c5e2..0e84cae01 100644 --- a/app/frontend/src/javascript/components/store/show-order.tsx +++ b/app/frontend/src/javascript/components/store/show-order.tsx @@ -147,7 +147,7 @@ export const ShowOrder: React.FC = ({ orderId, currentUser, onEr {isPrivileged() && order.user &&
{t('app.shared.store.show_order.client')} -

order.user.name

+

{order.user.name}

}
diff --git a/app/frontend/src/javascript/controllers/admin/orders.js b/app/frontend/src/javascript/controllers/admin/orders.js index ba2b00cce..fb28f9465 100644 --- a/app/frontend/src/javascript/controllers/admin/orders.js +++ b/app/frontend/src/javascript/controllers/admin/orders.js @@ -9,7 +9,7 @@ Application.Controllers.controller('AdminShowOrdersController', ['$rootScope', ' /* PRIVATE SCOPE */ /* PUBLIC SCOPE */ - $scope.orderToken = $transition$.params().token; + $scope.orderId = $transition$.params().id; /** * Callback triggered in case of error diff --git a/app/frontend/templates/admin/orders/show.html b/app/frontend/templates/admin/orders/show.html index e2274ce0c..289a14aaa 100644 --- a/app/frontend/templates/admin/orders/show.html +++ b/app/frontend/templates/admin/orders/show.html @@ -15,5 +15,5 @@ {{ 'app.admin.store.back_to_list' }}
- - \ No newline at end of file + + diff --git a/app/services/orders/order_service.rb b/app/services/orders/order_service.rb index da86f15f3..184d79987 100644 --- a/app/services/orders/order_service.rb +++ b/app/services/orders/order_service.rb @@ -15,6 +15,8 @@ class Orders::OrderService orders = orders.where(statistic_profile_id: statistic_profile_id) elsif current_user.member? orders = orders.where(statistic_profile_id: current_user.statistic_profile.id) + else + orders = orders.where.not(statistic_profile_id: nil) end orders = orders.where.not(state: 'cart') if current_user.member? orders = orders.order(created_at: filters[:page].present? ? filters[:sort] : 'DESC') From 007f7d55ba7072f01716d4cb2a78564141f1dabb Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 14 Sep 2022 09:26:29 +0200 Subject: [PATCH 147/361] (bug) add product to cart button quantity min error --- app/services/cart/add_item_service.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/cart/add_item_service.rb b/app/services/cart/add_item_service.rb index c60cff026..8f8dfa013 100644 --- a/app/services/cart/add_item_service.rb +++ b/app/services/cart/add_item_service.rb @@ -7,11 +7,11 @@ class Cart::AddItemService raise Cart::InactiveProductError unless orderable.is_active - quantity = orderable.quantity_min > quantity.to_i ? orderable.quantity_min : quantity.to_i + item = order.order_items.find_by(orderable: orderable) + quantity = orderable.quantity_min > quantity.to_i && item.nil? ? orderable.quantity_min : quantity.to_i raise Cart::OutStockError if quantity > orderable.stock['external'] - item = order.order_items.find_by(orderable: orderable) if item.nil? item = order.order_items.new(quantity: quantity, orderable: orderable, amount: orderable.amount) else From 9b3a1c0634ec6c93edd7b709721f4d2e971881c8 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 14 Sep 2022 09:37:14 +0200 Subject: [PATCH 148/361] (feat) add cart status color --- app/frontend/src/javascript/lib/order.ts | 2 ++ app/frontend/src/stylesheets/modules/store/order-item.scss | 3 ++- app/frontend/src/stylesheets/modules/store/orders.scss | 5 +++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/frontend/src/javascript/lib/order.ts b/app/frontend/src/javascript/lib/order.ts index af2447e02..7f8fd6766 100644 --- a/app/frontend/src/javascript/lib/order.ts +++ b/app/frontend/src/javascript/lib/order.ts @@ -53,6 +53,8 @@ export default class OrderLib { */ static statusColor = (order: Order) => { switch (order.state) { + case 'cart': + return 'cart'; case 'payment': if (order.payment_state === 'failed') { return 'error'; diff --git a/app/frontend/src/stylesheets/modules/store/order-item.scss b/app/frontend/src/stylesheets/modules/store/order-item.scss index 75b5a9201..ee8bb94a1 100644 --- a/app/frontend/src/stylesheets/modules/store/order-item.scss +++ b/app/frontend/src/stylesheets/modules/store/order-item.scss @@ -17,6 +17,7 @@ } .fab-state-label { --status-color: var(--success); + &.cart { --status-color: var(--secondary-light); } &.error { --status-color: var(--alert); } &.canceled { --status-color: var(--alert-light); } &.pending { --status-color: var(--information); } @@ -45,4 +46,4 @@ } p { @include text-base(600); } } -} \ No newline at end of file +} diff --git a/app/frontend/src/stylesheets/modules/store/orders.scss b/app/frontend/src/stylesheets/modules/store/orders.scss index 789725992..2157ef980 100644 --- a/app/frontend/src/stylesheets/modules/store/orders.scss +++ b/app/frontend/src/stylesheets/modules/store/orders.scss @@ -21,7 +21,7 @@ } .show-order { - &-nav { + &-nav { max-width: 1600px; margin: 0 auto; @include grid-col(12); @@ -102,9 +102,10 @@ .fab-state-label { --status-color: var(--success); + &.cart { --status-color: var(--secondary-light); } &.error { --status-color: var(--alert); } &.canceled { --status-color: var(--alert-light); } &.pending { --status-color: var(--information); } &.normal { --status-color: var(--success); } } -} \ No newline at end of file +} From 6b224d7db1f5720a77632478ff0031fc62bcb4f9 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 14 Sep 2022 14:51:54 +0200 Subject: [PATCH 149/361] (feat) alert unsaved changes if the user tries to quit the product form, he will be alerted about unsaved changes, if any --- .../javascript/components/base/fab-modal.tsx | 15 ++- .../components/form/unsaved-form-alert.tsx | 98 +++++++++++++++++-- .../components/store/product-form.tsx | 2 +- app/frontend/src/javascript/lib/deferred.ts | 22 +++++ config/locales/app.shared.en.yml | 4 + 5 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 app/frontend/src/javascript/lib/deferred.ts diff --git a/app/frontend/src/javascript/components/base/fab-modal.tsx b/app/frontend/src/javascript/components/base/fab-modal.tsx index 84fa446d6..0318f3dea 100644 --- a/app/frontend/src/javascript/components/base/fab-modal.tsx +++ b/app/frontend/src/javascript/components/base/fab-modal.tsx @@ -23,6 +23,7 @@ interface FabModalProps { customHeader?: ReactNode, customFooter?: ReactNode, onConfirm?: (event: BaseSyntheticEvent) => void, + onClose?: (event: BaseSyntheticEvent) => void, preventConfirm?: boolean, onCreation?: () => void, onConfirmSendFormId?: string, @@ -31,7 +32,7 @@ interface FabModalProps { /** * This component is a template for a modal dialog that wraps the application style */ -export const FabModal: React.FC = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation, onConfirmSendFormId }) => { +export const FabModal: React.FC = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, onClose, preventConfirm, onCreation, onConfirmSendFormId }) => { const { t } = useTranslation('shared'); useEffect(() => { @@ -40,12 +41,20 @@ export const FabModal: React.FC = ({ title, isOpen, toggleModal, } }, [isOpen]); + /** + * Callback triggered when the user request to close the modal without confirming. + */ + const handleClose = (event) => { + if (typeof onClose === 'function') onClose(event); + toggleModal(); + }; + return ( - {closeButton && {t('app.shared.fab_modal.close')}} + onRequestClose={handleClose}> + {closeButton && {t('app.shared.fab_modal.close')}}
{!customHeader &&

{ title }

} {customHeader && customHeader} diff --git a/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx b/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx index 007789eda..87e93a7ea 100644 --- a/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx +++ b/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx @@ -1,7 +1,10 @@ -import React, { PropsWithChildren, useEffect } from 'react'; +import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react'; import { UIRouter } from '@uirouter/angularjs'; import { FormState } from 'react-hook-form/dist/types/form'; import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FabModal } from '../base/fab-modal'; +import Deferred from '../../lib/deferred'; +import { useTranslation } from 'react-i18next'; interface UnsavedFormAlertProps { uiRouter: UIRouter, @@ -9,20 +12,101 @@ interface UnsavedFormAlertProps { } /** - * Alert the user about unsaved changes in the given form, before leaving the current page + * Alert the user about unsaved changes in the given form, before leaving the current page. + * This component is highly dependent of these external libraries: + * - [react-hook-form](https://react-hook-form.com/) + * - [ui-router](https://ui-router.github.io/) */ export const UnsavedFormAlert = ({ uiRouter, formState, children }: PropsWithChildren>) => { + const { t } = useTranslation('shared'); + + const [showAlertModal, setShowAlertModal] = useState(false); + const [promise, setPromise] = useState>(null); + const [dirty, setDirty] = useState(formState.isDirty); + + useEffect(() => { + const submitStatus = (!formState.isSubmitting && (!formState.isSubmitted || !formState.isSubmitSuccessful)); + setDirty(submitStatus && Object.keys(formState.dirtyFields).length > 0); + }, [formState]); + + /** + * Check if the current form is dirty. If so, show the confirmation modal and return a promise + */ + const alertOnDirtyForm = (isDirty: boolean): Promise|void => { + if (isDirty) { + toggleAlertModal(); + const userChoicePromise = new Deferred(); + setPromise(userChoicePromise); + return userChoicePromise.promise; + } + }; + + // memoised version of the alertOnDirtyForm function, will be updated only when the form becames dirty + const alertDirty = useCallback<() => Promise|void>(() => alertOnDirtyForm(dirty), [dirty]); + + // we should place this useEffect after the useCallback declaration (because it's a scoped variable) useEffect(() => { const { transitionService, globals: { current } } = uiRouter; - transitionService.onBefore({ from: current.name }, () => { - const { isDirty } = formState; - console.log('transition start', isDirty); - }); - }, []); + const deregisters = transitionService.onBefore({ from: current.name }, alertDirty); + return () => { + deregisters(); + }; + }, [alertDirty]); + + /** + * When the user tries to close the current page (tab/window), we alert him about unsaved changes + */ + const alertOnExit = (event: BeforeUnloadEvent, isDirty: boolean) => { + if (isDirty) { + event.preventDefault(); + event.returnValue = ''; + } + }; + + // memoised version of the alertOnExit function, will be updated only when the form becames dirty + const alertExit = useCallback<(event: BeforeUnloadEvent) => void>((event) => alertOnExit(event, dirty), [dirty]); + + // we should place this useEffect after the useCallback declaration (because it's a scoped variable) + useEffect(() => { + window.addEventListener('beforeunload', alertExit); + return () => { + window.removeEventListener('beforeunload', alertExit); + }; + }, [alertExit]); + + /** + * Hide/show the alert modal "you have some unsaved content, are you sure you want to leave?" + */ + const toggleAlertModal = () => { + setShowAlertModal(!showAlertModal); + }; + + /** + * Callback triggered when the user has choosen: continue and exit + */ + const handleConfirmation = () => { + promise.resolve(true); + }; + + /** + * Callback triggered when the user has choosen: cancel and stay + */ + const handleCancel = () => { + promise.resolve(false); + }; return (
{children} + + {t('app.shared.unsaved_form_alert.confirmation_message')} +
); }; diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index e0f8cf4e1..6bb1a36d6 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -227,8 +227,8 @@ export const ProductForm: React.FC = ({ product, title, onSucc {t('app.admin.store.product_form.save')}
- +

setStockTab(false)}>{t('app.admin.store.product_form.product_parameters')}

setStockTab(true)}>{t('app.admin.store.product_form.stock_management')}

diff --git a/app/frontend/src/javascript/lib/deferred.ts b/app/frontend/src/javascript/lib/deferred.ts new file mode 100644 index 000000000..811adc07e --- /dev/null +++ b/app/frontend/src/javascript/lib/deferred.ts @@ -0,0 +1,22 @@ +// This is a kind of promise you can resolve from outside the function callback. +// Credits to https://stackoverflow.com/a/71158892/1039377 +export default class Deferred { + public readonly promise: Promise; + private resolveFn!: (value: T | PromiseLike) => void; + private rejectFn!: (reason?: unknown) => void; + + public constructor () { + this.promise = new Promise((resolve, reject) => { + this.resolveFn = resolve; + this.rejectFn = reject; + }); + } + + public reject (reason?: unknown): void { + this.rejectFn(reason); + } + + public resolve (param: T): void { + this.resolveFn(param); + } +} diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index c3c5e65ef..a3976daa9 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -564,3 +564,7 @@ en: order_item: total: "Total" client: "Client" + unsaved_form_alert: + modal_title: "You have some unsaved changes" + confirmation_message: "If you leave this page, your changes will be lost. Are you sure you want to continue?" + confirmation_button: "Yes, don't save" From 850076f79a7c01edd9d7a161b628ae38462f6b1a Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 14 Sep 2022 15:19:12 +0200 Subject: [PATCH 150/361] (bug) undefined method due to merge conflict undefined method `amount_multiplied_by_hundred' for ProductService:Class --- app/services/product_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/product_service.rb b/app/services/product_service.rb index d88169ab1..1bd34d137 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -25,7 +25,7 @@ class ProductService end # amount params multiplied by hundred - def self.amount_multiplied_by_hundred(amount) + def amount_multiplied_by_hundred(amount) if amount.present? v = amount.to_f From b87355bc5a178b98cb0955f1a4f5d8d3dd131f48 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 14 Sep 2022 19:54:24 +0200 Subject: [PATCH 151/361] (feat) orders filter by admin --- .../javascript/components/cart/store-cart.tsx | 6 +- .../components/store/orders-dashboard.tsx | 2 +- .../javascript/components/store/orders.tsx | 176 ++++++++++++------ .../components/user/member-select.tsx | 28 ++- app/frontend/src/javascript/lib/order.ts | 10 +- app/frontend/src/javascript/models/order.ts | 5 +- .../stylesheets/modules/store/order-item.scss | 2 +- .../src/stylesheets/modules/store/orders.scss | 2 +- app/models/order.rb | 5 +- app/services/cart/find_or_create_service.rb | 12 +- app/services/orders/order_service.rb | 19 +- app/services/payments/payment_concern.rb | 3 +- app/services/payments/payzen_service.rb | 2 +- app/services/payments/stripe_service.rb | 2 +- app/views/api/orders/_order.json.jbuilder | 2 +- app/views/api/orders/index.json.jbuilder | 2 +- config/locales/app.admin.en.yml | 11 +- config/locales/app.shared.en.yml | 4 +- ...145334_remove_payment_state_from_orders.rb | 5 + db/schema.rb | 3 +- 20 files changed, 188 insertions(+), 113 deletions(-) create mode 100644 db/migrate/20220914145334_remove_payment_state_from_orders.rb diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index 644251959..c30749490 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -82,7 +82,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, * Handle payment */ const handlePaymentSuccess = (data: Order): void => { - if (data.payment_state === 'paid') { + if (data.state === 'paid') { setPaymentModal(false); window.location.href = '/#!/store'; onSuccess(t('app.public.store_cart.checkout_success')); @@ -94,8 +94,8 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, /** * Change cart's customer by admin/manger */ - const handleChangeMember = (userId: number): void => { - setCart({ ...cart, user: { id: userId, role: 'member' } }); + const handleChangeMember = (user: User): void => { + setCart({ ...cart, user: { id: user.id, role: 'member' } }); }; /** diff --git a/app/frontend/src/javascript/components/store/orders-dashboard.tsx b/app/frontend/src/javascript/components/store/orders-dashboard.tsx index 5dba6c459..8fa16226c 100644 --- a/app/frontend/src/javascript/components/store/orders-dashboard.tsx +++ b/app/frontend/src/javascript/components/store/orders-dashboard.tsx @@ -31,7 +31,7 @@ export const OrdersDashboard: React.FC = ({ currentUser, o const [orders, setOrders] = useState>([]); const [pageCount, setPageCount] = useState(0); const [currentPage, setCurrentPage] = useState(1); - const [totalCount, setTotalCount] = useState(1); + const [totalCount, setTotalCount] = useState(0); useEffect(() => { OrderAPI.index({}).then(res => { diff --git a/app/frontend/src/javascript/components/store/orders.tsx b/app/frontend/src/javascript/components/store/orders.tsx index 351d9f260..788a46874 100644 --- a/app/frontend/src/javascript/components/store/orders.tsx +++ b/app/frontend/src/javascript/components/store/orders.tsx @@ -12,9 +12,10 @@ import { OrderItem } from './order-item'; import { MemberSelect } from '../user/member-select'; import { User } from '../../models/user'; import { FormInput } from '../form/form-input'; -import { TDateISODate } from '../../typings/date-iso'; import OrderAPI from '../../api/order'; -import { Order } from '../../models/order'; +import { Order, OrderIndexFilter } from '../../models/order'; +import { FabPagination } from '../base/fab-pagination'; +import { TDateISO } from '../../typings/date-iso'; declare const Application: IApplication; @@ -32,7 +33,7 @@ type selectOption = { value: number, label: string }; /** * Option format, expected by checklist */ -type checklistOption = { value: number, label: string }; +type checklistOption = { value: string, label: string }; /** * Admin list of orders @@ -42,23 +43,26 @@ type checklistOption = { value: number, label: string }; const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { const { t } = useTranslation('admin'); - const { register, getValues } = useForm(); + const { register, getValues, setValue } = useForm(); const [orders, setOrders] = useState>([]); - const [filters, setFilters] = useImmer(initFilters); - const [clearFilters, setClearFilters] = useState(false); + const [filters, setFilters] = useImmer(initFilters); const [accordion, setAccordion] = useState({}); + const [pageCount, setPageCount] = useState(0); + const [totalCount, setTotalCount] = useState(0); + const [reference, setReference] = useState(filters.reference); + const [states, setStates] = useState>(filters.states); + const [user, setUser] = useState(); + const [periodFrom, setPeriodFrom] = useState(); + const [periodTo, setPeriodTo] = useState(); useEffect(() => { - OrderAPI.index({}).then(res => { + OrderAPI.index(filters).then(res => { + setPageCount(res.total_pages); + setTotalCount(res.total_count); setOrders(res.data); }).catch(onError); - }, []); - - useEffect(() => { - applyFilters(); - setClearFilters(false); - }, [clearFilters]); + }, [filters]); /** * Create a new order @@ -68,21 +72,43 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { }; const statusOptions: checklistOption[] = [ - { value: 0, label: t('app.admin.store.orders.status.error') }, - { value: 1, label: t('app.admin.store.orders.status.canceled') }, - { value: 2, label: t('app.admin.store.orders.status.pending') }, - { value: 3, label: t('app.admin.store.orders.status.under_preparation') }, - { value: 4, label: t('app.admin.store.orders.status.paid') }, - { value: 5, label: t('app.admin.store.orders.status.ready') }, - { value: 6, label: t('app.admin.store.orders.status.collected') }, - { value: 7, label: t('app.admin.store.orders.status.refunded') } + { value: 'cart', label: t('app.admin.store.orders.state.cart') }, + { value: 'paid', label: t('app.admin.store.orders.state.paid') }, + { value: 'payment_failed', label: t('app.admin.store.orders.state.payment_failed') }, + { value: 'in_progress', label: t('app.admin.store.orders.state.in_progress') }, + { value: 'ready', label: t('app.admin.store.orders.state.ready') }, + { value: 'canceled', label: t('app.admin.store.orders.state.canceled') } ]; /** * Apply filters */ - const applyFilters = () => { - console.log('Apply filters:', filters); + const applyFilters = (filterType: string) => { + return () => { + setFilters(draft => { + switch (filterType) { + case 'reference': + draft.reference = reference; + break; + case 'states': + draft.states = states; + break; + case 'user': + draft.user_id = user.id; + break; + case 'period': + if (periodFrom && periodTo) { + draft.period_from = periodFrom; + draft.period_to = periodTo; + } else { + draft.period_from = ''; + draft.period_to = ''; + } + break; + default: + } + }); + }; }; /** @@ -90,8 +116,13 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { */ const clearAllFilters = () => { setFilters(initFilters); - setClearFilters(true); - console.log('Clear all filters'); + setReference(''); + setStates([]); + setUser(null); + setPeriodFrom(null); + setPeriodTo(null); + setValue('period_from', ''); + setValue('period_to', ''); }; /** @@ -103,40 +134,54 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { { value: 1, label: t('app.admin.store.orders.sort.oldest') } ]; }; + /** * Display option: sorting */ const handleSorting = (option: selectOption) => { - console.log('Sort option:', option); + setFilters(draft => { + draft.sort = option.value ? 'ASC' : 'DESC'; + }); + }; + + /** + * Filter: by reference + */ + const handleReferenceChanged = (value: string) => { + setReference(value); }; /** * Filter: by status */ - const handleSelectStatus = (s: checklistOption, checked) => { - const list = [...filters.status]; + const handleSelectStatus = (s: checklistOption, checked: boolean) => { + const list = [...states]; checked - ? list.push(s) - : list.splice(list.indexOf(s), 1); - setFilters(draft => { - return { ...draft, status: list }; - }); + ? list.push(s.value) + : list.splice(list.indexOf(s.value), 1); + setStates(list); }; /** * Filter: by member */ - const handleSelectMember = (userId: number) => { - setFilters(draft => { - return { ...draft, memberId: userId }; - }); + const handleSelectMember = (user: User) => { + setUser(user); }; /** * Filter: by period */ - const handlePeriod = () => { - console.log(getValues(['period_from', 'period_to'])); + const handlePeriodChanged = (period: string) => { + return (event: React.ChangeEvent) => { + const value = event.target.value; + if (period === 'period_from') { + setPeriodFrom(value); + } + if (period === 'period_to') { + setPeriodTo(value); + } + }; }; /** @@ -146,6 +191,15 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { setAccordion({ ...accordion, [id]: state }); }; + /** + * Handle orders pagination + */ + const handlePagination = (page: number) => { + setFilters(draft => { + draft.page = page; + }); + }; + return (
@@ -164,6 +218,12 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { {t('app.admin.store.orders.filter_clear')}
+
+ {filters.reference &&
{filters.reference}
} + {filters.states.length > 0 &&
{filters.states.join(', ')}
} + {filters.user_id > 0 &&
{user?.name}
} + {filters.period_from &&
{filters.period_from} - {filters.period_to}
} +
= ({ currentUser, onSuccess, onError }) => { >
- - {t('app.admin.store.orders.filter_apply')} + handleReferenceChanged(event.target.value)}/> + {t('app.admin.store.orders.filter_apply')}
@@ -186,12 +246,12 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => {
{statusOptions.map(s => ( ))}
- {t('app.admin.store.orders.filter_apply')} + {t('app.admin.store.orders.filter_apply')}
= ({ currentUser, onSuccess, onError }) => { >
- - {t('app.admin.store.orders.filter_apply')} + + {t('app.admin.store.orders.filter_apply')}
@@ -217,13 +277,15 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { from to
- {t('app.admin.store.orders.filter_apply')} + {t('app.admin.store.orders.filter_apply')}
@@ -232,7 +294,7 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => {
@@ -241,6 +303,9 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { ))}
+ {orders.length > 0 && + + }
); @@ -256,18 +321,9 @@ const OrdersWrapper: React.FC = (props) => { Application.Components.component('orders', react2angular(OrdersWrapper, ['currentUser', 'onSuccess', 'onError'])); -interface Filters { - reference: string, - status: checklistOption[], - memberId: number, - period_from: TDateISODate, - period_to: TDateISODate -} - -const initFilters: Filters = { +const initFilters: OrderIndexFilter = { reference: '', - status: [], - memberId: null, - period_from: null, - period_to: null + states: [], + page: 1, + sort: 'DESC' }; diff --git a/app/frontend/src/javascript/components/user/member-select.tsx b/app/frontend/src/javascript/components/user/member-select.tsx index dfcadf35e..04e7c116f 100644 --- a/app/frontend/src/javascript/components/user/member-select.tsx +++ b/app/frontend/src/javascript/components/user/member-select.tsx @@ -6,7 +6,8 @@ import { User } from '../../models/user'; interface MemberSelectProps { defaultUser?: User, - onSelected?: (userId: number) => void, + value?: User, + onSelected?: (user: { id: number, name: string }) => void, noHeader?: boolean } @@ -19,22 +20,31 @@ type selectOption = { value: number, label: string }; /** * This component renders the member select for manager. */ -export const MemberSelect: React.FC = ({ defaultUser, onSelected, noHeader }) => { +export const MemberSelect: React.FC = ({ defaultUser, value, onSelected, noHeader }) => { const { t } = useTranslation('public'); - const [value, setValue] = useState(); + const [option, setOption] = useState(); useEffect(() => { if (defaultUser) { - setValue({ value: defaultUser.id, label: defaultUser.name }); + setOption({ value: defaultUser.id, label: defaultUser.name }); } }, []); useEffect(() => { - if (!defaultUser && value) { - onSelected(value.value); + if (!defaultUser && option) { + onSelected({ id: option.value, name: option.label }); } }, [defaultUser]); + useEffect(() => { + if (value && value?.id !== option?.value) { + setOption({ value: value.id, label: value.name }); + } + if (!value) { + setOption(null); + } + }, [value]); + /** * search members by name */ @@ -52,8 +62,8 @@ export const MemberSelect: React.FC = ({ defaultUser, onSelec * callback for handle select changed */ const onChange = (v: selectOption) => { - setValue(v); - onSelected(v.value); + setOption(v); + onSelected({ id: v.value, name: v.label }); }; return ( @@ -68,7 +78,7 @@ export const MemberSelect: React.FC = ({ defaultUser, onSelec loadOptions={loadMembers} defaultOptions onChange={onChange} - value={value} + value={option} /> ); diff --git a/app/frontend/src/javascript/lib/order.ts b/app/frontend/src/javascript/lib/order.ts index 7f8fd6766..111c6e8b0 100644 --- a/app/frontend/src/javascript/lib/order.ts +++ b/app/frontend/src/javascript/lib/order.ts @@ -55,11 +55,8 @@ export default class OrderLib { switch (order.state) { case 'cart': return 'cart'; - case 'payment': - if (order.payment_state === 'failed') { - return 'error'; - } - return 'normal'; + case 'payment_failed': + return 'error'; case 'canceled': return 'canceled'; case 'in_progress': @@ -73,9 +70,6 @@ export default class OrderLib { * Returns a status text according to the status */ static statusText = (order: Order) => { - if (order.state === 'payment') { - return `payment_${order.payment_state}`; - } return order.state; }; } diff --git a/app/frontend/src/javascript/models/order.ts b/app/frontend/src/javascript/models/order.ts index 1ab6d28a0..4c01540ef 100644 --- a/app/frontend/src/javascript/models/order.ts +++ b/app/frontend/src/javascript/models/order.ts @@ -16,7 +16,6 @@ export interface Order { operator_profile_id?: number, reference?: string, state?: string, - payment_state?: string, total?: number, coupon?: Coupon, created_at?: TDateISO, @@ -54,7 +53,11 @@ export interface OrderIndex { } export interface OrderIndexFilter { + reference?: string, user_id?: number, page?: number, sort?: 'DESC'|'ASC' + states?: Array, + period_from?: string, + period_to?: string } diff --git a/app/frontend/src/stylesheets/modules/store/order-item.scss b/app/frontend/src/stylesheets/modules/store/order-item.scss index ee8bb94a1..399a5fe9a 100644 --- a/app/frontend/src/stylesheets/modules/store/order-item.scss +++ b/app/frontend/src/stylesheets/modules/store/order-item.scss @@ -17,7 +17,7 @@ } .fab-state-label { --status-color: var(--success); - &.cart { --status-color: var(--secondary-light); } + &.cart { --status-color: var(--secondary-dark); } &.error { --status-color: var(--alert); } &.canceled { --status-color: var(--alert-light); } &.pending { --status-color: var(--information); } diff --git a/app/frontend/src/stylesheets/modules/store/orders.scss b/app/frontend/src/stylesheets/modules/store/orders.scss index 2157ef980..f57042b35 100644 --- a/app/frontend/src/stylesheets/modules/store/orders.scss +++ b/app/frontend/src/stylesheets/modules/store/orders.scss @@ -102,7 +102,7 @@ .fab-state-label { --status-color: var(--success); - &.cart { --status-color: var(--secondary-light); } + &.cart { --status-color: var(--secondary-dark); } &.error { --status-color: var(--alert); } &.canceled { --status-color: var(--alert-light); } &.pending { --status-color: var(--information); } diff --git a/app/models/order.rb b/app/models/order.rb index 8bdc995c4..bdc4f734b 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -9,12 +9,9 @@ class Order < PaymentDocument has_many :order_items, dependent: :destroy has_one :payment_gateway_object, as: :item - ALL_STATES = %w[cart payment in_progress ready canceled return].freeze + ALL_STATES = %w[cart paid payment_failed refunded in_progress ready canceled return].freeze enum state: ALL_STATES.zip(ALL_STATES).to_h - PAYMENT_STATES = %w[paid failed refunded].freeze - enum payment_state: PAYMENT_STATES.zip(PAYMENT_STATES).to_h - validates :token, :state, presence: true before_create :add_environment diff --git a/app/services/cart/find_or_create_service.rb b/app/services/cart/find_or_create_service.rb index ec506c0e2..1e3a8d5ab 100644 --- a/app/services/cart/find_or_create_service.rb +++ b/app/services/cart/find_or_create_service.rb @@ -47,11 +47,11 @@ class Cart::FindOrCreateService end @order = nil if @order && !@user && (@order.statistic_profile_id.present? || @order.operator_profile_id.present?) if @order && @order.statistic_profile_id.present? && Order.where(statistic_profile_id: @order.statistic_profile_id, - payment_state: 'paid').where('created_at > ?', @order.created_at).last.present? + state: 'paid').where('created_at > ?', @order.created_at).last.present? @order = nil end if @order && @order.operator_profile_id.present? && Order.where(operator_profile_id: @order.operator_profile_id, - payment_state: 'paid').where('created_at > ?', @order.created_at).last.present? + state: 'paid').where('created_at > ?', @order.created_at).last.present? @order = nil end end @@ -60,7 +60,7 @@ class Cart::FindOrCreateService def set_last_cart_if_user_login if @user&.member? last_paid_order = Order.where(statistic_profile_id: @user.statistic_profile.id, - payment_state: 'paid').last + state: 'paid').last @order = if last_paid_order Order.where(statistic_profile_id: @user.statistic_profile.id, state: 'cart').where('created_at > ?', last_paid_order.created_at).last @@ -70,7 +70,7 @@ class Cart::FindOrCreateService end if @user&.privileged? last_paid_order = Order.where(operator_profile_id: @user.invoicing_profile.id, - payment_state: 'paid').last + state: 'paid').last @order = if last_paid_order Order.where(operator_profile_id: @user.invoicing_profile.id, state: 'cart').where('created_at > ?', last_paid_order.created_at).last @@ -85,7 +85,7 @@ class Cart::FindOrCreateService last_unpaid_order = nil if @user&.member? last_paid_order = Order.where(statistic_profile_id: @user.statistic_profile.id, - payment_state: 'paid').last + state: 'paid').last last_unpaid_order = if last_paid_order Order.where(statistic_profile_id: @user.statistic_profile.id, state: 'cart').where('created_at > ?', last_paid_order.created_at).last @@ -95,7 +95,7 @@ class Cart::FindOrCreateService end if @user&.privileged? last_paid_order = Order.where(operator_profile_id: @user.invoicing_profile.id, - payment_state: 'paid').last + state: 'paid').last last_unpaid_order = if last_paid_order Order.where(operator_profile_id: @user.invoicing_profile.id, state: 'cart').where('created_at > ?', last_paid_order.created_at).last diff --git a/app/services/orders/order_service.rb b/app/services/orders/order_service.rb index 184d79987..362fce859 100644 --- a/app/services/orders/order_service.rb +++ b/app/services/orders/order_service.rb @@ -18,15 +18,28 @@ class Orders::OrderService else orders = orders.where.not(statistic_profile_id: nil) end + + orders = orders.where(reference: filters[:reference]) if filters[:reference].present? && current_user.privileged? + + if filters[:states].present? + state = filters[:states].split(',') + orders = orders.where(state: state) unless state.empty? + end + + if filters[:period_from].present? && filters[:period_to].present? + orders = orders.where(created_at: DateTime.parse(filters[:period_from])..DateTime.parse(filters[:period_to]).end_of_day) + end + orders = orders.where.not(state: 'cart') if current_user.member? - orders = orders.order(created_at: filters[:page].present? ? filters[:sort] : 'DESC') - orders = orders.page(filters[:page]).per(ORDERS_PER_PAGE) if filters[:page].present? + orders = orders.order(created_at: filters[:sort] || 'DESC') + total_count = orders.count + orders = orders.page(filters[:page] || 1).per(ORDERS_PER_PAGE) { data: orders, page: filters[:page] || 1, total_pages: orders.page(1).per(ORDERS_PER_PAGE).total_pages, page_size: ORDERS_PER_PAGE, - total_count: orders.count + total_count: total_count } end diff --git a/app/services/payments/payment_concern.rb b/app/services/payments/payment_concern.rb index 3bcf3afab..22e921527 100644 --- a/app/services/payments/payment_concern.rb +++ b/app/services/payments/payment_concern.rb @@ -27,8 +27,7 @@ module Payments::PaymentConcern else payment_method end - order.state = 'payment' - order.payment_state = 'paid' + order.state = 'paid' if payment_id && payment_type order.payment_gateway_object = PaymentGatewayObject.new(gateway_object_id: payment_id, gateway_object_type: payment_type) end diff --git a/app/services/payments/payzen_service.rb b/app/services/payments/payzen_service.rb index 30e1adeac..09902962b 100644 --- a/app/services/payments/payzen_service.rb +++ b/app/services/payments/payzen_service.rb @@ -31,7 +31,7 @@ class Payments::PayzenService o = payment_success(order, coupon_code, 'card', payment_id, 'PayZen::Order') { order: o } else - order.update(payment_state: 'failed') + order.update(state: 'payment_failed') { order: order, payment: { error: { statusText: payzen_order['answer'] } } } end end diff --git a/app/services/payments/stripe_service.rb b/app/services/payments/stripe_service.rb index c4c8a09b5..479077cd6 100644 --- a/app/services/payments/stripe_service.rb +++ b/app/services/payments/stripe_service.rb @@ -39,7 +39,7 @@ class Payments::StripeService o = payment_success(order, coupon_code, 'card', intent.id, intent.class.name) { order: o } else - order.update(payment_state: 'failed') + order.update(state: 'payment_failed') { order: order, payment: { error: { statusText: 'payment failed' } } } end end diff --git a/app/views/api/orders/_order.json.jbuilder b/app/views/api/orders/_order.json.jbuilder index e75303dfd..7bc6ca3e0 100644 --- a/app/views/api/orders/_order.json.jbuilder +++ b/app/views/api/orders/_order.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true json.extract! order, :id, :token, :statistic_profile_id, :operator_profile_id, :reference, :state, :created_at, :updated_at, :invoice_id, - :payment_method, :payment_state + :payment_method json.total order.total / 100.0 if order.total.present? json.payment_date order.invoice.created_at if order.invoice_id.present? json.wallet_amount order.wallet_amount / 100.0 if order.wallet_amount.present? diff --git a/app/views/api/orders/index.json.jbuilder b/app/views/api/orders/index.json.jbuilder index da28a45f6..6bc854380 100644 --- a/app/views/api/orders/index.json.jbuilder +++ b/app/views/api/orders/index.json.jbuilder @@ -5,7 +5,7 @@ json.total_pages @result[:total_pages] json.page_size @result[:page_size] json.total_count @result[:total_count] json.data @result[:data] do |order| - json.extract! order, :id, :statistic_profile_id, :reference, :state, :created_at, :payment_state, :updated_at + json.extract! order, :id, :statistic_profile_id, :reference, :state, :created_at, :updated_at json.total order.total / 100.0 if order.total.present? json.paid_total order.paid_total / 100.0 if order.paid_total.present? if order&.statistic_profile&.user diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 2d76ee343..752c8f26b 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -2043,14 +2043,13 @@ en: filter_status: "By status" filter_client: "By client" filter_period: "By period" - status: - error: "Payment error" - canceled: "Canceled" - pending: "Pending payment" - under_preparation: "Under preparation" + state: + cart: 'Cart' + in_progress: 'In progress' paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" ready: "Ready" - collected: "Collected" refunded: "Refunded" sort: newest: "Newest first" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 93ec1473f..f04ccc12e 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -567,7 +567,7 @@ en: state: cart: 'Cart' in_progress: 'In progress' - payment_paid: "Paid" + paid: "Paid" payment_failed: "Payment error" canceled: "Canceled" ready: "Ready" @@ -592,7 +592,7 @@ en: state: cart: 'Cart' in_progress: 'In progress' - payment_paid: "Paid" + paid: "Paid" payment_failed: "Payment error" canceled: "Canceled" ready: "Ready" diff --git a/db/migrate/20220914145334_remove_payment_state_from_orders.rb b/db/migrate/20220914145334_remove_payment_state_from_orders.rb new file mode 100644 index 000000000..3e439536e --- /dev/null +++ b/db/migrate/20220914145334_remove_payment_state_from_orders.rb @@ -0,0 +1,5 @@ +class RemovePaymentStateFromOrders < ActiveRecord::Migration[5.2] + def change + remove_column :orders, :payment_state + end +end diff --git a/db/schema.rb b/db/schema.rb index 72a4f8e53..7aeb1eca9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_09_09_131300) do +ActiveRecord::Schema.define(version: 2022_09_14_145334) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -467,7 +467,6 @@ ActiveRecord::Schema.define(version: 2022_09_09_131300) do t.integer "total" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "payment_state" t.integer "wallet_amount" t.integer "wallet_transaction_id" t.string "payment_method" From 947c69c4edd73c5654a3333ba6aaab2a434e916c Mon Sep 17 00:00:00 2001 From: Du Peng Date: Thu, 15 Sep 2022 12:38:34 +0200 Subject: [PATCH 152/361] (feat) improve orders filter --- .../javascript/components/store/orders.tsx | 83 ++++++++++++++----- .../components/store/store-list-header.tsx | 4 +- app/frontend/src/javascript/models/order.ts | 4 + 3 files changed, 67 insertions(+), 24 deletions(-) diff --git a/app/frontend/src/javascript/components/store/orders.tsx b/app/frontend/src/javascript/components/store/orders.tsx index 788a46874..96370d303 100644 --- a/app/frontend/src/javascript/components/store/orders.tsx +++ b/app/frontend/src/javascript/components/store/orders.tsx @@ -15,13 +15,11 @@ import { FormInput } from '../form/form-input'; import OrderAPI from '../../api/order'; import { Order, OrderIndexFilter } from '../../models/order'; import { FabPagination } from '../base/fab-pagination'; -import { TDateISO } from '../../typings/date-iso'; declare const Application: IApplication; interface OrdersProps { currentUser?: User, - onSuccess: (message: string) => void, onError: (message: string) => void, } /** @@ -35,28 +33,36 @@ type selectOption = { value: number, label: string }; */ type checklistOption = { value: string, label: string }; +const initFilters: OrderIndexFilter = { + reference: '', + states: [], + page: 1, + sort: 'DESC' +}; + +const FablabOrdersFilters = 'FablabOrdersFilters'; + /** * Admin list of orders */ -// TODO: delete next eslint disable -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { +const Orders: React.FC = ({ currentUser, onError }) => { const { t } = useTranslation('admin'); - const { register, getValues, setValue } = useForm(); + const { register, setValue } = useForm(); const [orders, setOrders] = useState>([]); - const [filters, setFilters] = useImmer(initFilters); + const [filters, setFilters] = useImmer(window[FablabOrdersFilters] || initFilters); const [accordion, setAccordion] = useState({}); const [pageCount, setPageCount] = useState(0); const [totalCount, setTotalCount] = useState(0); const [reference, setReference] = useState(filters.reference); const [states, setStates] = useState>(filters.states); - const [user, setUser] = useState(); - const [periodFrom, setPeriodFrom] = useState(); - const [periodTo, setPeriodTo] = useState(); + const [user, setUser] = useState<{ id: number, name?: string }>(filters.user); + const [periodFrom, setPeriodFrom] = useState(filters.period_from); + const [periodTo, setPeriodTo] = useState(filters.period_to); useEffect(() => { + window[FablabOrdersFilters] = filters; OrderAPI.index(filters).then(res => { setPageCount(res.total_pages); setTotalCount(res.total_count); @@ -95,6 +101,7 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { break; case 'user': draft.user_id = user.id; + draft.user = user; break; case 'period': if (periodFrom && periodTo) { @@ -111,6 +118,40 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { }; }; + /** + * Clear filter by type + */ + const removefilter = (filterType: string) => { + return () => { + setFilters(draft => { + draft.page = 1; + draft.sort = 'DESC'; + switch (filterType) { + case 'reference': + draft.reference = ''; + setReference(''); + break; + case 'states': + draft.states = []; + setStates([]); + break; + case 'user': + delete draft.user_id; + delete draft.user; + setUser(null); + break; + case 'period': + draft.period_from = ''; + draft.period_to = ''; + setPeriodFrom(null); + setPeriodTo(null); + break; + default: + } + }); + }; + }; + /** * Clear filters */ @@ -219,10 +260,10 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => {
- {filters.reference &&
{filters.reference}
} - {filters.states.length > 0 &&
{filters.states.join(', ')}
} - {filters.user_id > 0 &&
{user?.name}
} - {filters.period_from &&
{filters.period_from} - {filters.period_to}
} + {filters.reference &&
{filters.reference} x
} + {filters.states.length > 0 &&
{filters.states.join(', ')} x
} + {filters.user_id > 0 &&
{user?.name} x
} + {filters.period_from &&
{filters.period_from} - {filters.period_to} x
}
= ({ currentUser, onSuccess, onError }) => { >
- + {t('app.admin.store.orders.filter_apply')}
@@ -278,11 +319,13 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => { to
{t('app.admin.store.orders.filter_apply')} @@ -296,6 +339,7 @@ const Orders: React.FC = ({ currentUser, onSuccess, onError }) => {
@@ -319,11 +363,4 @@ const OrdersWrapper: React.FC = (props) => { ); }; -Application.Components.component('orders', react2angular(OrdersWrapper, ['currentUser', 'onSuccess', 'onError'])); - -const initFilters: OrderIndexFilter = { - reference: '', - states: [], - page: 1, - sort: 'DESC' -}; +Application.Components.component('orders', react2angular(OrdersWrapper, ['currentUser', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/store-list-header.tsx b/app/frontend/src/javascript/components/store/store-list-header.tsx index 28856c70c..d69e78d5f 100644 --- a/app/frontend/src/javascript/components/store/store-list-header.tsx +++ b/app/frontend/src/javascript/components/store/store-list-header.tsx @@ -7,6 +7,7 @@ interface StoreListHeaderProps { productsCount: number, selectOptions: selectOption[], onSelectOptionsChange: (option: selectOption) => void, + selectValue?: number, switchLabel?: string, switchChecked?: boolean, onSwitch?: (boolean) => void @@ -20,7 +21,7 @@ interface StoreListHeaderProps { /** * Renders an accordion item */ -export const StoreListHeader: React.FC = ({ productsCount, selectOptions, onSelectOptionsChange, switchLabel, switchChecked, onSwitch }) => { +export const StoreListHeader: React.FC = ({ productsCount, selectOptions, onSelectOptionsChange, switchLabel, switchChecked, onSwitch, selectValue }) => { const { t } = useTranslation('admin'); // Styles the React-select component @@ -47,6 +48,7 @@ export const StoreListHeader: React.FC = ({ productsCount, handleAction(option)} + value={currentAction} styles={customStyles} /> } diff --git a/app/frontend/src/javascript/lib/order.ts b/app/frontend/src/javascript/lib/order.ts index 111c6e8b0..7206ee243 100644 --- a/app/frontend/src/javascript/lib/order.ts +++ b/app/frontend/src/javascript/lib/order.ts @@ -55,8 +55,12 @@ export default class OrderLib { switch (order.state) { case 'cart': return 'cart'; + case 'paid': + return 'paid'; case 'payment_failed': return 'error'; + case 'ready': + return 'ready'; case 'canceled': return 'canceled'; case 'in_progress': diff --git a/app/frontend/src/stylesheets/modules/store/order-item.scss b/app/frontend/src/stylesheets/modules/store/order-item.scss index 399a5fe9a..50522ddf3 100644 --- a/app/frontend/src/stylesheets/modules/store/order-item.scss +++ b/app/frontend/src/stylesheets/modules/store/order-item.scss @@ -18,6 +18,8 @@ .fab-state-label { --status-color: var(--success); &.cart { --status-color: var(--secondary-dark); } + &.paid { --status-color: var(--success-light); } + &.ready { --status-color: var(--success); } &.error { --status-color: var(--alert); } &.canceled { --status-color: var(--alert-light); } &.pending { --status-color: var(--information); } diff --git a/app/frontend/src/stylesheets/modules/store/orders.scss b/app/frontend/src/stylesheets/modules/store/orders.scss index f57042b35..225ac4cb4 100644 --- a/app/frontend/src/stylesheets/modules/store/orders.scss +++ b/app/frontend/src/stylesheets/modules/store/orders.scss @@ -103,6 +103,8 @@ .fab-state-label { --status-color: var(--success); &.cart { --status-color: var(--secondary-dark); } + &.paid { --status-color: var(--success-light); } + &.ready { --status-color: var(--success); } &.error { --status-color: var(--alert); } &.canceled { --status-color: var(--alert-light); } &.pending { --status-color: var(--information); } diff --git a/app/models/notification_type.rb b/app/models/notification_type.rb index 834482649..4d0fec54a 100644 --- a/app/models/notification_type.rb +++ b/app/models/notification_type.rb @@ -69,6 +69,8 @@ class NotificationType notify_user_is_invalidated notify_user_proof_of_identity_refusal notify_admin_user_proof_of_identity_refusal + notify_user_order_is_ready + notify_user_order_is_canceled ] # deprecated: # - notify_member_subscribed_plan_is_changed diff --git a/app/models/order.rb b/app/models/order.rb index bdc4f734b..96d7c249d 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -8,6 +8,7 @@ class Order < PaymentDocument belongs_to :invoice has_many :order_items, dependent: :destroy has_one :payment_gateway_object, as: :item + has_many :order_activities, dependent: :destroy ALL_STATES = %w[cart paid payment_failed refunded in_progress ready canceled return].freeze enum state: ALL_STATES.zip(ALL_STATES).to_h diff --git a/app/models/order_activity.rb b/app/models/order_activity.rb new file mode 100644 index 000000000..b8703d867 --- /dev/null +++ b/app/models/order_activity.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# OrderActivity is a model for hold activity of order +class OrderActivity < ApplicationRecord + belongs_to :order + + TYPES = %w[paid payment_failed refunded in_progress ready canceled return note].freeze + enum activity_type: TYPES.zip(TYPES).to_h + + validates :activity_type, presence: true +end diff --git a/app/services/orders/cancel_order_service.rb b/app/services/orders/cancel_order_service.rb new file mode 100644 index 000000000..f42cb72e9 --- /dev/null +++ b/app/services/orders/cancel_order_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Provides methods for cancel an order +class Orders::CancelOrderService + def call(order, current_user) + raise ::UpdateOrderStateError if %w[cart payment_failed canceled refunded].include?(order.state) + + order.state = 'canceled' + ActiveRecord::Base.transaction do + activity = order.order_activities.create(activity_type: 'canceled', operator_profile_id: current_user.invoicing_profile.id) + order.save + NotificationCenter.call type: 'notify_user_order_is_canceled', + receiver: order.statistic_profile.user, + attached_object: activity + end + order.reload + end +end diff --git a/app/services/orders/order_ready_service.rb b/app/services/orders/order_ready_service.rb new file mode 100644 index 000000000..330bf83bb --- /dev/null +++ b/app/services/orders/order_ready_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Provides methods for set order to ready state +class Orders::OrderReadyService + def call(order, current_user, note = '') + raise ::UpdateOrderStateError if %w[cart payment_failed ready canceled refunded].include?(order.state) + + order.state = 'ready' + ActiveRecord::Base.transaction do + activity = order.order_activities.create(activity_type: 'ready', operator_profile_id: current_user.invoicing_profile.id, note: note) + order.save + NotificationCenter.call type: 'notify_user_order_is_ready', + receiver: order.statistic_profile.user, + attached_object: activity + end + order.reload + end +end diff --git a/app/services/orders/order_service.rb b/app/services/orders/order_service.rb index 362fce859..35663cd74 100644 --- a/app/services/orders/order_service.rb +++ b/app/services/orders/order_service.rb @@ -43,6 +43,12 @@ class Orders::OrderService } end + def self.update_state(order, current_user, state, note = nil) + return ::Orders::SetInProgressService.new.call(order, current_user) if state == 'in_progress' + return ::Orders::OrderReadyService.new.call(order, current_user, note) if state == 'ready' + return ::Orders::CancelOrderService.new.call(order, current_user) if state == 'canceled' + end + def in_stock?(order, stock_type = 'external') order.order_items.each do |item| return false if item.orderable.stock[stock_type] < item.quantity diff --git a/app/services/orders/refund_order_service.rb b/app/services/orders/refund_order_service.rb new file mode 100644 index 000000000..e69de29bb diff --git a/app/services/orders/set_in_progress_service.rb b/app/services/orders/set_in_progress_service.rb new file mode 100644 index 000000000..91f32d068 --- /dev/null +++ b/app/services/orders/set_in_progress_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Provides methods for set in progress state to order +class Orders::SetInProgressService + def call(order, current_user) + raise ::UpdateOrderStateError if %w[cart payment_failed in_progress canceled refunded].include?(order.state) + + order.state = 'in_progress' + order.order_activities.push(OrderActivity.new(activity_type: 'in_progress', operator_profile_id: current_user.invoicing_profile.id)) + order.save + order.reload + end +end diff --git a/app/services/payments/payment_concern.rb b/app/services/payments/payment_concern.rb index 22e921527..7c16034aa 100644 --- a/app/services/payments/payment_concern.rb +++ b/app/services/payments/payment_concern.rb @@ -31,6 +31,7 @@ module Payments::PaymentConcern if payment_id && payment_type order.payment_gateway_object = PaymentGatewayObject.new(gateway_object_id: payment_id, gateway_object_type: payment_type) end + order.order_activities.create(activity_type: 'paid') order.order_items.each do |item| ProductService.update_stock(item.orderable, 'external', 'sold', -item.quantity, item.id) end diff --git a/app/views/api/notifications/_notify_user_order_is_canceled.json.jbuilder b/app/views/api/notifications/_notify_user_order_is_canceled.json.jbuilder new file mode 100644 index 000000000..a004ebc14 --- /dev/null +++ b/app/views/api/notifications/_notify_user_order_is_canceled.json.jbuilder @@ -0,0 +1,2 @@ +json.title notification.notification_type +json.description t('.order_canceled', REFERENCE: notification.attached_object.order.reference) diff --git a/app/views/api/notifications/_notify_user_order_is_ready.json.jbuilder b/app/views/api/notifications/_notify_user_order_is_ready.json.jbuilder new file mode 100644 index 000000000..c44ed960e --- /dev/null +++ b/app/views/api/notifications/_notify_user_order_is_ready.json.jbuilder @@ -0,0 +1,2 @@ +json.title notification.notification_type +json.description t('.order_ready', REFERENCE: notification.attached_object.order.reference) diff --git a/app/views/notifications_mailer/notify_user_order_is_canceled.erb b/app/views/notifications_mailer/notify_user_order_is_canceled.erb new file mode 100644 index 000000000..19c08f7da --- /dev/null +++ b/app/views/notifications_mailer/notify_user_order_is_canceled.erb @@ -0,0 +1,5 @@ +<%= render 'notifications_mailer/shared/hello', recipient: @recipient %> + +

+ <%= t('.body.notify_user_order_is_canceled', REFERENCE: @attached_object.order.reference) %> +

diff --git a/app/views/notifications_mailer/notify_user_order_is_ready.erb b/app/views/notifications_mailer/notify_user_order_is_ready.erb new file mode 100644 index 000000000..337c9439a --- /dev/null +++ b/app/views/notifications_mailer/notify_user_order_is_ready.erb @@ -0,0 +1,8 @@ +<%= render 'notifications_mailer/shared/hello', recipient: @recipient %> + +

+<%= t('.body.notify_user_order_is_ready', REFERENCE: @attached_object.order.reference) %> +

+

+ <%= @attached_object.note %> +

diff --git a/config/locales/en.yml b/config/locales/en.yml index f1443cee1..0eb43b7f7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -407,6 +407,10 @@ en: refusal: "Your proof of identity are not accepted" notify_admin_user_proof_of_identity_refusal: refusal: "Member's proof of identity %{NAME} refused." + notify_user_order_is_ready: + order_ready: "Your command %{REFERENCE} is ready" + notify_user_order_is_canceled: + order_canceled: "Your command %{REFERENCE} is canceled" #statistics tools for admins statistics: subscriptions: "Subscriptions" diff --git a/config/locales/mails.en.yml b/config/locales/mails.en.yml index 4c8a16b76..8040a7ef8 100644 --- a/config/locales/mails.en.yml +++ b/config/locales/mails.en.yml @@ -374,3 +374,11 @@ en: user_proof_of_identity_files_refusal: "Member %{NAME}'s supporting documents were rejected by %{OPERATOR}:" shared: hello: "Hello %{user_name}" + notify_user_order_is_ready: + subject: "Your command is ready" + body: + notify_user_order_is_ready: "Your command %{REFERENCE} is ready:" + notify_user_order_is_canceled: + subject: "Your command is canceled" + body: + notify_user_order_is_canceled: "Your command %{REFERENCE} is canceled:" diff --git a/db/migrate/20220915133100_create_order_activities.rb b/db/migrate/20220915133100_create_order_activities.rb new file mode 100644 index 000000000..0d87fa87c --- /dev/null +++ b/db/migrate/20220915133100_create_order_activities.rb @@ -0,0 +1,12 @@ +class CreateOrderActivities < ActiveRecord::Migration[5.2] + def change + create_table :order_activities do |t| + t.belongs_to :order, foreign_key: true + t.references :operator_profile, foreign_key: { to_table: 'invoicing_profiles' } + t.string :activity_type + t.text :note + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7aeb1eca9..6aaa272cb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_09_14_145334) do +ActiveRecord::Schema.define(version: 2022_09_15_133100) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -445,6 +445,17 @@ ActiveRecord::Schema.define(version: 2022_09_14_145334) do t.datetime "updated_at", null: false end + create_table "order_activities", force: :cascade do |t| + t.bigint "order_id" + t.bigint "operator_profile_id" + t.string "activity_type" + t.text "note" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["operator_profile_id"], name: "index_order_activities_on_operator_profile_id" + t.index ["order_id"], name: "index_order_activities_on_order_id" + end + create_table "order_items", force: :cascade do |t| t.bigint "order_id" t.string "orderable_type" @@ -1170,6 +1181,8 @@ ActiveRecord::Schema.define(version: 2022_09_14_145334) do add_foreign_key "invoices", "statistic_profiles" add_foreign_key "invoices", "wallet_transactions" add_foreign_key "invoicing_profiles", "users" + add_foreign_key "order_activities", "invoicing_profiles", column: "operator_profile_id" + add_foreign_key "order_activities", "orders" add_foreign_key "order_items", "orders" add_foreign_key "orders", "coupons" add_foreign_key "orders", "invoices" From 53004767bfd8d8a1569fbdb79758c9f30052a9b2 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Fri, 16 Sep 2022 11:38:11 +0200 Subject: [PATCH 154/361] (feat) add order action ready/in_progress/canceled --- .../components/store/order-actions.tsx | 129 ++++++++++++++++++ .../components/store/show-order.tsx | 84 ++---------- app/services/orders/cancel_order_service.rb | 3 + config/locales/app.shared.en.yml | 18 +++ 4 files changed, 162 insertions(+), 72 deletions(-) create mode 100644 app/frontend/src/javascript/components/store/order-actions.tsx diff --git a/app/frontend/src/javascript/components/store/order-actions.tsx b/app/frontend/src/javascript/components/store/order-actions.tsx new file mode 100644 index 000000000..4cab008e9 --- /dev/null +++ b/app/frontend/src/javascript/components/store/order-actions.tsx @@ -0,0 +1,129 @@ +import React, { useState, BaseSyntheticEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import Select from 'react-select'; +import { FabModal } from '../base/fab-modal'; +import OrderAPI from '../../api/order'; +import { Order } from '../../models/order'; + +interface OrderActionsProps { + order: Order, + onSuccess: (order: Order, message: string) => void, + onError: (message: string) => void, +} + +/** +* Option format, expected by react-select +* @see https://github.com/JedWatson/react-select +*/ +type selectOption = { value: string, label: string }; + +/** + * Actions for an order + */ +export const OrderActions: React.FC = ({ order, onSuccess, onError }) => { + const { t } = useTranslation('shared'); + const [currentAction, setCurrentAction] = useState(); + const [modalIsOpen, setModalIsOpen] = useState(false); + const [readyNote, setReadyNote] = useState(''); + + // Styles the React-select component + const customStyles = { + control: base => ({ + ...base, + width: '20ch', + backgroundColor: 'transparent' + }), + indicatorSeparator: () => ({ + display: 'none' + }) + }; + + /** + * Close the action confirmation modal + */ + const closeModal = (): void => { + setModalIsOpen(false); + setCurrentAction(null); + }; + + /** + * Creates sorting options to the react-select format + */ + const buildOptions = (): Array => { + let actions = []; + switch (order.state) { + case 'paid': + actions = actions.concat(['in_progress', 'ready', 'canceled', 'refunded']); + break; + case 'payment_failed': + actions = actions.concat(['canceled']); + break; + case 'in_progress': + actions = actions.concat(['ready', 'canceled', 'refunded']); + break; + case 'ready': + actions = actions.concat(['canceled', 'refunded']); + break; + case 'canceled': + actions = actions.concat(['refunded']); + break; + default: + actions = []; + } + return actions.map(action => { + return { value: action, label: t(`app.shared.store.order_actions.state.${action}`) }; + }); + }; + + /** + * Callback after selecting an action + */ + const handleAction = (action: selectOption) => { + setCurrentAction(action); + setModalIsOpen(true); + }; + + /** + * Callback after confirm an action + */ + const handleActionConfirmation = () => { + OrderAPI.updateState(order, currentAction.value, readyNote).then(data => { + onSuccess(data, t(`app.shared.store.order_actions.order_${currentAction.value}_success`)); + setCurrentAction(null); + setModalIsOpen(false); + }).catch((e) => { + onError(e); + setCurrentAction(null); + setModalIsOpen(false); + }); + }; + + return ( + <> +