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')}