mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-02-19 13:54:25 +01:00
(feat) stock management: create/show
This commit is contained in:
parent
45bac88b26
commit
c968f7b1aa
@ -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
|
||||
|
@ -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<void> = await apiClient.delete(`/api/products/${productId}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async stockMovements (productId: number, page = 1): Promise<Array<ProductStockMovement>> {
|
||||
const res: AxiosResponse<Array<ProductStockMovement>> = await apiClient.get(`/api/products/${productId}/stock_movements?page=${page}`);
|
||||
return res?.data;
|
||||
}
|
||||
}
|
||||
|
@ -18,12 +18,13 @@ interface FormInputProps<TFieldValues, TInputType> extends FormComponent<TFieldV
|
||||
placeholder?: string,
|
||||
step?: number | 'any',
|
||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
|
||||
nullable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is a template for an input component to use within React Hook Form
|
||||
*/
|
||||
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept }: FormInputProps<TFieldValues, TInputType>) => {
|
||||
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false }: FormInputProps<TFieldValues, TInputType>) => {
|
||||
/**
|
||||
* Debounced (ie. temporised) version of the 'on change' callback.
|
||||
*/
|
||||
@ -57,8 +58,8 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
|
||||
<input id={id}
|
||||
{...register(id as FieldPath<TFieldValues>, {
|
||||
...rules,
|
||||
valueAsNumber: type === 'number',
|
||||
valueAsDate: type === 'date',
|
||||
setValueAs: v => (v === null && nullable) ? null : (type === 'number' ? parseInt(v, 10) : v),
|
||||
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
|
||||
onChange: (e) => { handleChange(e); }
|
||||
})}
|
||||
|
@ -55,7 +55,7 @@ const ProductCategories: React.FC<ProductCategoriesProps> = ({ onSuccess, onErro
|
||||
*/
|
||||
const refreshCategories = () => {
|
||||
ProductCategoryAPI.index().then(data => {
|
||||
setProductCategories(new ProductLib().sortCategories(data));
|
||||
setProductCategories(ProductLib.sortCategories(data));
|
||||
}).catch((error) => onError(error));
|
||||
};
|
||||
|
||||
|
@ -54,7 +54,7 @@ export const ProductForm: React.FC<ProductFormProps> = ({ 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<ProductFormProps> = ({ product, title, onSucc
|
||||
<p className={stockTab ? 'is-active' : ''} onClick={() => setStockTab(true)}>{t('app.admin.store.product_form.stock_management')}</p>
|
||||
</div>
|
||||
{stockTab
|
||||
? <ProductStockForm product={product} register={register} control={control} formState={formState} onError={onError} onSuccess={onSuccess} />
|
||||
? <ProductStockForm currentFormValues={output as Product} register={register} control={control} formState={formState} setValue={setValue} onError={onError} onSuccess={onSuccess} />
|
||||
: <section>
|
||||
<div className="subgrid">
|
||||
<FormInput id="name"
|
||||
|
@ -1,38 +1,53 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Product } from '../../models/product';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { Control, FormState } from 'react-hook-form/dist/types/form';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Select from 'react-select';
|
||||
import { PencilSimple } from 'phosphor-react';
|
||||
import { ArrayPath, Path, useFieldArray, UseFormRegister } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { Control, FormState, UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Product, ProductStockMovement, StockMovementReason, StockType } from '../../models/product';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
import { FormSwitch } from '../form/form-switch';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import Select from 'react-select';
|
||||
import { FabAlert } from '../base/fab-alert';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { PencilSimple } from 'phosphor-react';
|
||||
import { FabModal, ModalSize } from '../base/fab-modal';
|
||||
import { ProductStockModal } from './product-stock-modal';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FabStateLabel } from '../base/fab-state-label';
|
||||
import ProductAPI from '../../api/product';
|
||||
import FormatLib from '../../lib/format';
|
||||
import { FieldPathValue } from 'react-hook-form/dist/types/path';
|
||||
import ProductLib from '../../lib/product';
|
||||
|
||||
interface ProductStockFormProps<TFieldValues, TContext extends object> {
|
||||
product: Product,
|
||||
currentFormValues: Product,
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
control: Control<TFieldValues, TContext>,
|
||||
formState: FormState<TFieldValues>,
|
||||
setValue: UseFormSetValue<TFieldValues>,
|
||||
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 = <TFieldValues extends FieldValues, TContext extends object> ({ product, register, control, formState, onError, onSuccess }: ProductStockFormProps<TFieldValues, TContext>) => {
|
||||
export const ProductStockForm = <TFieldValues extends FieldValues, TContext extends object> ({ currentFormValues, register, control, formState, setValue, onError }: ProductStockFormProps<TFieldValues, TContext>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [activeThreshold, setActiveThreshold] = useState<boolean>(false);
|
||||
// is the modal open?
|
||||
const [activeThreshold, setActiveThreshold] = useState<boolean>(currentFormValues.low_stock_threshold != null);
|
||||
// is the update stock modal open?
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [stockMovements, setStockMovements] = useState<Array<ProductStockMovement>>([]);
|
||||
|
||||
const { fields, append } = useFieldArray({ control, name: 'product_stock_movements_attributes' as ArrayPath<TFieldValues> });
|
||||
|
||||
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 = <TFieldValues extends FieldValues, TContext exte
|
||||
})
|
||||
};
|
||||
|
||||
type selectOption = { value: number, label: string };
|
||||
type reasonSelectOption = { value: StockMovementReason, label: string };
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildEventsOptions = (): Array<selectOption> => {
|
||||
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<reasonSelectOption> => {
|
||||
return (['inward_stock', 'returned', 'cancelled', 'inventory_fix', 'sold', 'missing', 'damaged'] as Array<StockMovementReason>).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<selectOption> => {
|
||||
const buildStocksOptions = (): Array<typeSelectOption> => {
|
||||
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 = <TFieldValues extends FieldValues, TContext exte
|
||||
*/
|
||||
const toggleStockThreshold = (checked: boolean) => {
|
||||
setActiveThreshold(checked);
|
||||
setValue(
|
||||
'low_stock_threshold' as Path<TFieldValues>,
|
||||
(checked ? DEFAULT_LOW_STOCK_THRESHOLD : null) as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<section className='product-stock-form'>
|
||||
<h4>Stock à jour <span>00/00/0000 - 00H30</span></h4>
|
||||
<h4>{t('app.admin.store.product_stock_form.stock_up_to_date')}
|
||||
<span>{t('app.admin.store.product_stock_form.date_time', {
|
||||
DATE: FormatLib.date(lastStockUpdate()),
|
||||
TIME: FormatLib.time((lastStockUpdate()))
|
||||
})}</span>
|
||||
</h4>
|
||||
<div></div>
|
||||
<div className="stock-item">
|
||||
<p className='title'>Product name</p>
|
||||
<p className='title'>{currentFormValues?.name}</p>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.internal')}</span>
|
||||
<p>00</p>
|
||||
<p>{currentFormValues?.stock?.internal}</p>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.external')}</span>
|
||||
<p>000</p>
|
||||
<p>{currentFormValues?.stock?.external}</p>
|
||||
</div>
|
||||
<FabButton onClick={toggleModal} icon={<PencilSimple size={20} weight="fill" />} className="is-black">Modifier</FabButton>
|
||||
</div>
|
||||
@ -139,19 +171,18 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
|
||||
{activeThreshold && <>
|
||||
<FabStateLabel>{t('app.admin.store.product_stock_form.low_stock')}</FabStateLabel>
|
||||
<div className="threshold-data-content">
|
||||
<FormInput id="threshold"
|
||||
type="number"
|
||||
register={register}
|
||||
rules={{ required: true, min: 1 }}
|
||||
step={1}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_form.threshold_level')} />
|
||||
<FormSwitch control={control}
|
||||
id="threshold_alert"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_form.threshold_alert')}
|
||||
defaultValue={activeThreshold}
|
||||
onChange={toggleStockThresholdAlert} />
|
||||
<FormInput id="low_stock_threshold"
|
||||
type="number"
|
||||
register={register}
|
||||
rules={{ required: activeThreshold, min: 1 }}
|
||||
step={1}
|
||||
formState={formState}
|
||||
nullable
|
||||
label={t('app.admin.store.product_stock_form.threshold_level')} />
|
||||
<FormSwitch control={control}
|
||||
id="low_stock_alert"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_form.threshold_alert')} />
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
@ -163,7 +194,7 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
|
||||
<div className='sort-events'>
|
||||
<p>{t('app.admin.store.product_stock_form.event_type')}</p>
|
||||
<Select
|
||||
options={buildEventsOptions()}
|
||||
options={buildReasonsOptions()}
|
||||
onChange={evt => eventsOptionsChange(evt)}
|
||||
styles={customStyles}
|
||||
/>
|
||||
@ -177,33 +208,36 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stock-history">
|
||||
{stockMovements.map(movement => <div className="stock-history" key={movement.id}>
|
||||
<div className="stock-item">
|
||||
<p className='title'>Product name</p>
|
||||
<p>00/00/0000</p>
|
||||
<p className='title'>{currentFormValues.name}</p>
|
||||
<p>{FormatLib.date(movement.date)}</p>
|
||||
<div className="group">
|
||||
<span>[stock type]</span>
|
||||
<p>00</p>
|
||||
<span>{movement.stock_type}</span>
|
||||
<p>{movement.quantity}</p>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.event_type')}</span>
|
||||
<p>[event type]</p>
|
||||
<p>{movement.reason}</p>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.stock_level')}</span>
|
||||
<p>000</p>
|
||||
<p>{movement.remaining_stock}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
<FabModal title={t('app.admin.store.product_stock_form.modal_title')}
|
||||
width={ModalSize.large}
|
||||
isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
closeButton>
|
||||
<ProductStockModal product={product} register={register} control={control} formState={formState} onError={onError} onSuccess={onSuccess} />
|
||||
</FabModal>
|
||||
<ProductStockModal onError={onError}
|
||||
onSuccess={onNewStockMovement}
|
||||
isOpen={isOpen}
|
||||
toggleModal={toggleModal} />
|
||||
{fields.map((newMovement, index) => (
|
||||
<div key={index}>
|
||||
<FormInput id={`product_stock_movements_attributes.${index}.stock_type`} register={register} type="hidden" />
|
||||
<FormInput id={`product_stock_movements_attributes.${index}.quantity`} register={register} type="hidden" />
|
||||
<FormInput id={`product_stock_movements_attributes.${index}.reason`} register={register} type="hidden" />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
@ -1,22 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Product } from '../../models/product';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { Control, FormState } from 'react-hook-form/dist/types/form';
|
||||
import { ProductStockMovement, StockMovementReason, StockType } from '../../models/product';
|
||||
import { FormSelect } from '../form/form-select';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FabModal, ModalSize } from '../base/fab-modal';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import ProductLib from '../../lib/product';
|
||||
|
||||
type selectOption = { value: number, label: string };
|
||||
type reasonSelectOption = { value: StockMovementReason, label: string };
|
||||
type typeSelectOption = { value: StockType, label: string };
|
||||
|
||||
interface ProductStockModalProps<TFieldValues, TContext extends object> {
|
||||
product: Product,
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
control: Control<TFieldValues, TContext>,
|
||||
formState: FormState<TFieldValues>,
|
||||
onSuccess: (product: Product) => void,
|
||||
onError: (message: string) => void
|
||||
interface ProductStockModalProps {
|
||||
onSuccess: (movement: ProductStockMovement) => void,
|
||||
onError: (message: string) => void,
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -24,11 +23,13 @@ interface ProductStockModalProps<TFieldValues, TContext extends object> {
|
||||
*/
|
||||
// TODO: delete next eslint disable
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const ProductStockModal = <TFieldValues extends FieldValues, TContext extends object> ({ product, register, control, formState, onError, onSuccess }: ProductStockModalProps<TFieldValues, TContext>) => {
|
||||
export const ProductStockModal: React.FC<ProductStockModalProps> = ({ onError, onSuccess, isOpen, toggleModal }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [movement, setMovement] = useState<'in' | 'out'>('in');
|
||||
|
||||
const { handleSubmit, register, control, formState } = useForm<ProductStockMovement>();
|
||||
|
||||
/**
|
||||
* Toggle between adding or removing product from stock
|
||||
*/
|
||||
@ -37,63 +38,80 @@ export const ProductStockModal = <TFieldValues extends FieldValues, TContext ext
|
||||
setMovement(type);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered when the user validates the new stock movement.
|
||||
* We do not use handleSubmit() directly to prevent the propagaion of the "submit" event to the parent form
|
||||
*/
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
return handleSubmit((data: ProductStockMovement) => {
|
||||
onSuccess(data);
|
||||
toggleModal();
|
||||
})(event);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildEventsOptions = (): Array<selectOption> => {
|
||||
let options = [];
|
||||
movement === 'in'
|
||||
? options = [
|
||||
{ value: 0, label: t('app.admin.store.product_stock_modal.events.inward_stock') },
|
||||
{ value: 1, label: t('app.admin.store.product_stock_modal.events.returned') },
|
||||
{ value: 2, label: t('app.admin.store.product_stock_modal.events.canceled') }
|
||||
]
|
||||
: options = [
|
||||
{ value: 0, label: t('app.admin.store.product_stock_modal.events.sold') },
|
||||
{ value: 1, label: t('app.admin.store.product_stock_modal.events.missing') },
|
||||
{ value: 2, label: t('app.admin.store.product_stock_modal.events.damaged') }
|
||||
];
|
||||
return options;
|
||||
const buildEventsOptions = (): Array<reasonSelectOption> => {
|
||||
const options: Record<string, Array<StockMovementReason>> = {
|
||||
in: ['inward_stock', 'returned', 'cancelled', 'inventory_fix'],
|
||||
out: ['sold', 'missing', 'damaged']
|
||||
};
|
||||
|
||||
return options[movement].map(key => {
|
||||
return { value: key, label: t(ProductLib.stockMovementReasonTrKey(key)) };
|
||||
});
|
||||
};
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildStocksOptions = (): Array<selectOption> => {
|
||||
const buildStocksOptions = (): Array<typeSelectOption> => {
|
||||
return [
|
||||
{ value: 0, label: t('app.admin.store.product_stock_modal.internal') },
|
||||
{ value: 1, label: t('app.admin.store.product_stock_modal.external') }
|
||||
{ value: 'internal', label: t('app.admin.store.product_stock_modal.internal') },
|
||||
{ value: 'external', label: t('app.admin.store.product_stock_modal.external') }
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<form className='product-stock-modal'>
|
||||
<p className='subtitle'>{t('app.admin.store.product_stock_modal.new_event')}</p>
|
||||
<div className="movement">
|
||||
<button onClick={(evt) => toggleMovementType(evt, 'in')} className={movement === 'in' ? 'is-active' : ''}>
|
||||
{t('app.admin.store.product_stock_modal.addition')}
|
||||
</button>
|
||||
<button onClick={(evt) => toggleMovementType(evt, 'out')} className={movement === 'out' ? 'is-active' : ''}>
|
||||
{t('app.admin.store.product_stock_modal.withdrawal')}
|
||||
</button>
|
||||
</div>
|
||||
<FormSelect options={buildStocksOptions()}
|
||||
control={control}
|
||||
id="updated_stock_type"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.stocks')} />
|
||||
<FormInput id="updated_stock_quantity"
|
||||
type="number"
|
||||
register={register}
|
||||
rules={{ required: true, min: 1 }}
|
||||
step={1}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.quantity')} />
|
||||
<FormSelect options={buildEventsOptions()}
|
||||
control={control}
|
||||
id="updated_stock_event"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.event_type')} />
|
||||
<FabButton type='submit'>{t('app.admin.store.product_stock_modal.update_stock')} </FabButton>
|
||||
</form>
|
||||
<FabModal title={t('app.admin.store.product_stock_modal.modal_title')}
|
||||
width={ModalSize.large}
|
||||
isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
className="product-stock-modal"
|
||||
closeButton>
|
||||
<form onSubmit={onSubmit}>
|
||||
<p className='subtitle'>{t('app.admin.store.product_stock_modal.new_event')}</p>
|
||||
<div className="movement">
|
||||
<button onClick={(evt) => toggleMovementType(evt, 'in')} className={movement === 'in' ? 'is-active' : ''}>
|
||||
{t('app.admin.store.product_stock_modal.addition')}
|
||||
</button>
|
||||
<button onClick={(evt) => toggleMovementType(evt, 'out')} className={movement === 'out' ? 'is-active' : ''}>
|
||||
{t('app.admin.store.product_stock_modal.withdrawal')}
|
||||
</button>
|
||||
</div>
|
||||
<FormSelect options={buildStocksOptions()}
|
||||
control={control}
|
||||
id="stock_type"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.stocks')} />
|
||||
<FormInput id="quantity"
|
||||
type="number"
|
||||
register={register}
|
||||
rules={{ required: true, min: 1 }}
|
||||
step={1}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.quantity')} />
|
||||
<FormSelect options={buildEventsOptions()}
|
||||
control={control}
|
||||
id="reason"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.reason_type')} />
|
||||
<FabButton type='submit'>{t('app.admin.store.product_stock_modal.update_stock')} </FabButton>
|
||||
</form>
|
||||
</FabModal>
|
||||
);
|
||||
};
|
||||
|
@ -53,7 +53,7 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
||||
});
|
||||
|
||||
ProductCategoryAPI.index().then(data => {
|
||||
setProductCategories(new ProductLib().sortCategories(data));
|
||||
setProductCategories(ProductLib.sortCategories(data));
|
||||
}).catch(onError);
|
||||
|
||||
MachineAPI.index({ disabled: false }).then(data => {
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { ProductCategory } from '../models/product-category';
|
||||
import { StockMovementReason } from '../models/product';
|
||||
|
||||
export default class ProductLib {
|
||||
/**
|
||||
* Map product categories by position
|
||||
* @param categories unsorted categories, as returned by the API
|
||||
*/
|
||||
sortCategories = (categories: Array<ProductCategory>): Array<ProductCategory> => {
|
||||
static sortCategories = (categories: Array<ProductCategory>): Array<ProductCategory> => {
|
||||
const sortedCategories = categories
|
||||
.filter(c => !c.parent_id)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
@ -18,4 +19,11 @@ export default class ProductLib {
|
||||
});
|
||||
return sortedCategories;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the translation key associated with the given reason
|
||||
*/
|
||||
static stockMovementReasonTrKey = (reason: StockMovementReason): string => {
|
||||
return `app.admin.store.stock_movement_reason.${reason}`;
|
||||
};
|
||||
}
|
||||
|
@ -5,16 +5,24 @@ export interface ProductIndexFilter extends ApiFilter {
|
||||
is_active: boolean,
|
||||
}
|
||||
|
||||
export enum StockType {
|
||||
internal = 'internal',
|
||||
external = 'external'
|
||||
}
|
||||
export type StockType = 'internal' | 'external' | 'all';
|
||||
export type StockMovementReason = 'inward_stock' | 'returned' | 'cancelled' | 'inventory_fix' | 'sold' | 'missing' | 'damaged';
|
||||
|
||||
export interface Stock {
|
||||
internal: number,
|
||||
external: number,
|
||||
}
|
||||
|
||||
export interface ProductStockMovement {
|
||||
id?: number,
|
||||
product_id?: number,
|
||||
quantity?: number,
|
||||
reason?: StockMovementReason,
|
||||
stock_type?: StockType,
|
||||
remaining_stock?: number,
|
||||
date?: TDateISO
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id?: number,
|
||||
name: string,
|
||||
@ -29,6 +37,7 @@ export interface Product {
|
||||
low_stock_alert: boolean,
|
||||
low_stock_threshold?: number,
|
||||
machine_ids: number[],
|
||||
created_at?: TDateISO,
|
||||
product_files_attributes: Array<{
|
||||
id?: number,
|
||||
attachment?: File,
|
||||
@ -46,13 +55,5 @@ export interface Product {
|
||||
_destroy?: boolean,
|
||||
is_main?: boolean
|
||||
}>,
|
||||
product_stock_movements_attributes: Array<{
|
||||
id?: number,
|
||||
quantity?: number,
|
||||
reason?: string,
|
||||
stock_type?: string,
|
||||
remaining_stock?: number,
|
||||
date?: TDateISO,
|
||||
_destroy?: boolean
|
||||
}>,
|
||||
product_stock_movements_attributes: Array<ProductStockMovement>,
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ class ProductStockMovement < ApplicationRecord
|
||||
ALL_STOCK_TYPES = %w[internal external].freeze
|
||||
enum stock_type: ALL_STOCK_TYPES.zip(ALL_STOCK_TYPES).to_h
|
||||
|
||||
ALL_REASONS = %w[incoming_stock returned_by_customer cancelled_by_customer sold missing_from_inventory damaged].freeze
|
||||
ALL_REASONS = %w[inward_stock returned cancelled inventory_fix sold missing damaged].freeze
|
||||
enum reason: ALL_REASONS.zip(ALL_REASONS).to_h
|
||||
|
||||
validates :stock_type, presence: true
|
||||
@ -16,4 +16,10 @@ class ProductStockMovement < ApplicationRecord
|
||||
|
||||
validates :reason, presence: true
|
||||
validates :reason, inclusion: { in: ALL_REASONS }
|
||||
|
||||
before_create :set_date
|
||||
|
||||
def set_date
|
||||
self.date = DateTime.current
|
||||
end
|
||||
end
|
||||
|
@ -13,4 +13,8 @@ class ProductPolicy < ApplicationPolicy
|
||||
def destroy?
|
||||
user.privileged?
|
||||
end
|
||||
|
||||
def stock_movements?
|
||||
user.privileged?
|
||||
end
|
||||
end
|
||||
|
@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert,
|
||||
:low_stock_threshold, :machine_ids
|
||||
:low_stock_threshold, :machine_ids, :created_at
|
||||
json.description sanitize(product.description)
|
||||
json.amount product.amount / 100.0 if product.amount.present?
|
||||
json.product_files_attributes product.product_files do |f|
|
||||
@ -15,11 +15,3 @@ json.product_images_attributes product.product_images do |f|
|
||||
json.attachment_url f.attachment_url
|
||||
json.is_main f.is_main
|
||||
end
|
||||
json.product_stock_movements_attributes product.product_stock_movements do |s|
|
||||
json.id s.id
|
||||
json.quantity s.quantity
|
||||
json.reason s.reason
|
||||
json.stock_type s.stock_type
|
||||
json.remaining_stock s.remaining_stock
|
||||
json.date s.date
|
||||
end
|
||||
|
3
app/views/api/products/_stock_movement.json.jbuilder
Normal file
3
app/views/api/products/_stock_movement.json.jbuilder
Normal file
@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.extract! stock_movement, :id, :quantity, :reason, :stock_type, :remaining_stock, :date
|
6
app/views/api/products/stock_movements.json.jbuilder
Normal file
6
app/views/api/products/stock_movements.json.jbuilder
Normal file
@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.array! @movements do |movement|
|
||||
json.partial! 'api/products/stock_movement', stock_movement: movement
|
||||
json.extract! movement, :product_id
|
||||
end
|
@ -1990,6 +1990,8 @@ en:
|
||||
add_product_image: "Add an image"
|
||||
save: "Save"
|
||||
product_stock_form:
|
||||
stock_up_to_date: "Stock up to date"
|
||||
date_time: "{DATE} - {TIME}"
|
||||
low_stock_threshold: "Define a low stock threshold"
|
||||
stock_threshold_toggle: "Activate stock threshold"
|
||||
stock_threshold_information: "<strong>Information</strong></br>Define a low stock threshold and receive a notification when it's reached.<br>Above the threshold, the product is available in the store. When the threshold is reached, the product quantity is labeled as low."
|
||||
@ -2002,32 +2004,25 @@ en:
|
||||
external: "Public stock"
|
||||
all: "All types"
|
||||
stock_level: "Stock level"
|
||||
events:
|
||||
inward_stock: "Inward stock"
|
||||
returned: "Returned by client"
|
||||
canceled: "Canceled by client"
|
||||
sold: "Sold"
|
||||
missing: "Missing in stock"
|
||||
damaged: "Damaged product"
|
||||
events_history: "Events history"
|
||||
modal_title: "Manage stock"
|
||||
product_stock_modal:
|
||||
modal_title: "Manage stock"
|
||||
internal: "Private stock"
|
||||
external: "Public stock"
|
||||
new_event: "New stock event"
|
||||
addition: "Addition"
|
||||
withdrawal: "Withdrawal"
|
||||
update_stock: "Update stock"
|
||||
event_type: "Events:"
|
||||
reason_type: "Reason"
|
||||
stocks: "Stocks:"
|
||||
quantity: "Quantity"
|
||||
events:
|
||||
inward_stock: "Inward stock"
|
||||
returned: "Returned by client"
|
||||
canceled: "Canceled by client"
|
||||
sold: "Sold"
|
||||
missing: "Missing in stock"
|
||||
damaged: "Damaged product"
|
||||
stock_movement_reason:
|
||||
inward_stock: "Inward stock"
|
||||
returned: "Returned by client"
|
||||
canceled: "Canceled by client"
|
||||
inventory_fix: "Inventory fix"
|
||||
sold: "Sold"
|
||||
missing: "Missing in stock"
|
||||
damaged: "Damaged product"
|
||||
orders:
|
||||
heading: "Orders"
|
||||
create_order: "Create an order"
|
||||
@ -2068,4 +2063,4 @@ en:
|
||||
title: 'Settings'
|
||||
withdrawal_instructions: 'Product withdrawal instructions'
|
||||
withdrawal_info: "This text is displayed on the checkout page to inform the client about the products withdrawal method"
|
||||
save: "Save"
|
||||
save: "Save"
|
||||
|
@ -562,5 +562,5 @@ en:
|
||||
main_image: "Main image"
|
||||
store:
|
||||
order_item:
|
||||
total: "Total"
|
||||
client: "Client"
|
||||
total: "Total"
|
||||
client: "Client"
|
||||
|
@ -154,7 +154,9 @@ Rails.application.routes.draw do
|
||||
patch 'position', on: :member
|
||||
end
|
||||
|
||||
resources :products
|
||||
resources :products do
|
||||
get 'stock_movements', on: :member
|
||||
end
|
||||
resources :cart, only: %i[create] do
|
||||
put 'add_item', on: :collection
|
||||
put 'remove_item', on: :collection
|
||||
|
Loading…
x
Reference in New Issue
Block a user