From c968f7b1aaea382dca0f199a2787b8e0f6e40699 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Thu, 8 Sep 2022 17:51:48 +0200 Subject: [PATCH 01/23] (feat) stock management: create/show --- app/controllers/api/products_controller.rb | 9 +- app/frontend/src/javascript/api/product.ts | 7 +- .../javascript/components/form/form-input.tsx | 5 +- .../store/categories/product-categories.tsx | 2 +- .../components/store/product-form.tsx | 4 +- .../components/store/product-stock-form.tsx | 166 +++++++++++------- .../components/store/product-stock-modal.tsx | 136 +++++++------- .../javascript/components/store/products.tsx | 2 +- app/frontend/src/javascript/lib/product.ts | 10 +- app/frontend/src/javascript/models/product.ts | 27 +-- app/models/product_stock_movement.rb | 8 +- app/policies/product_policy.rb | 4 + app/views/api/products/_product.json.jbuilder | 10 +- .../products/_stock_movement.json.jbuilder | 3 + .../products/stock_movements.json.jbuilder | 6 + config/locales/app.admin.en.yml | 31 ++-- config/locales/app.shared.en.yml | 4 +- config/routes.rb | 4 +- 18 files changed, 260 insertions(+), 178 deletions(-) create mode 100644 app/views/api/products/_stock_movement.json.jbuilder create mode 100644 app/views/api/products/stock_movements.json.jbuilder diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index ff022b3eb..87786eb0b 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -6,6 +6,8 @@ class API::ProductsController < API::ApiController before_action :authenticate_user!, except: %i[index show] before_action :set_product, only: %i[update destroy] + MOVEMENTS_PER_PAGE = 10 + def index @products = ProductService.list(params) end @@ -43,6 +45,11 @@ class API::ProductsController < API::ApiController head :no_content end + def stock_movements + authorize Product + @movements = ProductStockMovement.where(product_id: params[:id]).order(date: :desc).page(params[:page]).per(MOVEMENTS_PER_PAGE) + end + private def set_product @@ -56,6 +63,6 @@ class API::ProductsController < API::ApiController machine_ids: [], product_files_attributes: %i[id attachment _destroy], product_images_attributes: %i[id attachment is_main _destroy], - product_stock_movements_attributes: %i[id quantity reason stock_type _destroy]) + product_stock_movements_attributes: %i[id quantity reason stock_type]) end end diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts index 6abf55f4a..9870a61a1 100644 --- a/app/frontend/src/javascript/api/product.ts +++ b/app/frontend/src/javascript/api/product.ts @@ -1,7 +1,7 @@ import apiClient from './clients/api-client'; import { AxiosResponse } from 'axios'; import { serialize } from 'object-to-formdata'; -import { Product, ProductIndexFilter } from '../models/product'; +import { Product, ProductIndexFilter, ProductStockMovement } from '../models/product'; import ApiLib from '../lib/api'; export default class ProductAPI { @@ -89,4 +89,9 @@ export default class ProductAPI { const res: AxiosResponse = await apiClient.delete(`/api/products/${productId}`); return res?.data; } + + static async stockMovements (productId: number, page = 1): Promise> { + const res: AxiosResponse> = await apiClient.get(`/api/products/${productId}/stock_movements?page=${page}`); + return res?.data; + } } diff --git a/app/frontend/src/javascript/components/form/form-input.tsx b/app/frontend/src/javascript/components/form/form-input.tsx index 8f103f943..31b04bec1 100644 --- a/app/frontend/src/javascript/components/form/form-input.tsx +++ b/app/frontend/src/javascript/components/form/form-input.tsx @@ -18,12 +18,13 @@ interface FormInputProps extends FormComponent) => void, + nullable?: boolean } /** * This component is a template for an input component to use within React Hook Form */ -export const FormInput = ({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept }: FormInputProps) => { +export const FormInput = ({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false }: FormInputProps) => { /** * Debounced (ie. temporised) version of the 'on change' callback. */ @@ -57,8 +58,8 @@ export const FormInput = ({ id, re , { ...rules, - valueAsNumber: type === 'number', valueAsDate: type === 'date', + setValueAs: v => (v === null && nullable) ? null : (type === 'number' ? parseInt(v, 10) : v), value: defaultValue as FieldPathValue>, onChange: (e) => { handleChange(e); } })} 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 058eebb6a..be45f7b8e 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -55,7 +55,7 @@ const ProductCategories: React.FC = ({ onSuccess, onErro */ const refreshCategories = () => { ProductCategoryAPI.index().then(data => { - setProductCategories(new ProductLib().sortCategories(data)); + setProductCategories(ProductLib.sortCategories(data)); }).catch((error) => onError(error)); }; diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index 496e50c46..ea7402b09 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -54,7 +54,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc useEffect(() => { ProductCategoryAPI.index().then(data => { - setProductCategories(buildSelectOptions(new ProductLib().sortCategories(data))); + setProductCategories(buildSelectOptions(ProductLib.sortCategories(data))); }).catch(onError); MachineAPI.index({ disabled: false }).then(data => { setMachines(buildChecklistOptions(data)); @@ -230,7 +230,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc

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

{stockTab - ? + ? :
{ - product: Product, + currentFormValues: Product, register: UseFormRegister, control: Control, formState: FormState, + setValue: UseFormSetValue, onSuccess: (product: Product) => void, onError: (message: string) => void, } +const DEFAULT_LOW_STOCK_THRESHOLD = 30; + /** * Form tab to manage a product's stock */ -export const ProductStockForm = ({ product, register, control, formState, onError, onSuccess }: ProductStockFormProps) => { +export const ProductStockForm = ({ currentFormValues, register, control, formState, setValue, onError }: ProductStockFormProps) => { const { t } = useTranslation('admin'); - const [activeThreshold, setActiveThreshold] = useState(false); - // is the modal open? + const [activeThreshold, setActiveThreshold] = useState(currentFormValues.low_stock_threshold != null); + // is the update stock modal open? const [isOpen, setIsOpen] = useState(false); + const [stockMovements, setStockMovements] = useState>([]); + + const { fields, append } = useFieldArray({ control, name: 'product_stock_movements_attributes' as ArrayPath }); + + useEffect(() => { + if (!currentFormValues?.id) return; + + ProductAPI.stockMovements(currentFormValues.id).then(setStockMovements).catch(onError); + }, []); // Styles the React-select component const customStyles = { @@ -47,41 +62,38 @@ export const ProductStockForm = => { - return [ - { value: 0, label: t('app.admin.store.product_stock_form.events.inward_stock') }, - { value: 1, label: t('app.admin.store.product_stock_form.events.returned') }, - { value: 2, label: t('app.admin.store.product_stock_form.events.canceled') }, - { value: 3, label: t('app.admin.store.product_stock_form.events.sold') }, - { value: 4, label: t('app.admin.store.product_stock_form.events.missing') }, - { value: 5, label: t('app.admin.store.product_stock_form.events.damaged') } - ]; + const buildReasonsOptions = (): Array => { + return (['inward_stock', 'returned', 'cancelled', 'inventory_fix', 'sold', 'missing', 'damaged'] as Array).map(key => { + return { value: key, label: t(ProductLib.stockMovementReasonTrKey(key)) }; + }); }; + + type typeSelectOption = { value: StockType, label: string }; /** * Creates sorting options to the react-select format */ - const buildStocksOptions = (): Array => { + const buildStocksOptions = (): Array => { return [ - { value: 0, label: t('app.admin.store.product_stock_form.internal') }, - { value: 1, label: t('app.admin.store.product_stock_form.external') }, - { value: 2, label: t('app.admin.store.product_stock_form.all') } + { value: 'internal', label: t('app.admin.store.product_stock_form.internal') }, + { value: 'external', label: t('app.admin.store.product_stock_form.external') }, + { value: 'all', label: t('app.admin.store.product_stock_form.all') } ]; }; /** * On events option change */ - const eventsOptionsChange = (evt: selectOption) => { + const eventsOptionsChange = (evt: reasonSelectOption) => { console.log('Event option:', evt); }; /** * On stocks option change */ - const stocksOptionsChange = (evt: selectOption) => { + const stocksOptionsChange = (evt: typeSelectOption) => { console.log('Stocks option:', evt); }; @@ -90,35 +102,55 @@ export const ProductStockForm = { setActiveThreshold(checked); + setValue( + 'low_stock_threshold' as Path, + (checked ? DEFAULT_LOW_STOCK_THRESHOLD : null) as UnpackNestedValue>> + ); }; /** - * Opens/closes the product category modal + * Opens/closes the product stock edition modal */ const toggleModal = (): void => { setIsOpen(!isOpen); }; /** - * Toggle stock threshold alert + * Triggered when a new product stock movement was added */ - const toggleStockThresholdAlert = (checked: boolean) => { - console.log('Low stock notification:', checked); + const onNewStockMovement = (movement): void => { + append({ ...movement }); + }; + + /** + * Return the data of the update of the stock for the current product + */ + const lastStockUpdate = () => { + if (stockMovements[0]) { + return stockMovements[0].date; + } else { + return currentFormValues?.created_at || new Date(); + } }; return (
-

Stock à jour 00/00/0000 - 00H30

+

{t('app.admin.store.product_stock_form.stock_up_to_date')}  + {t('app.admin.store.product_stock_form.date_time', { + DATE: FormatLib.date(lastStockUpdate()), + TIME: FormatLib.time((lastStockUpdate())) + })} +

-

Product name

+

{currentFormValues?.name}

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

00

+

{currentFormValues?.stock?.internal}

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

000

+

{currentFormValues?.stock?.external}

} className="is-black">Modifier
@@ -139,19 +171,18 @@ export const ProductStockForm = {t('app.admin.store.product_stock_form.low_stock')}
- - + +
}
@@ -163,7 +194,7 @@ export const ProductStockForm =

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

{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 10/23] (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 11/23] (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 12/23] (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 13/23] (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 14/23] (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 15/23] (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 16/23] (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 17/23] (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 18/23] (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 19/23] (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 20/23] (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 22/23] (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 ( + <> +