1
0
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:
Sylvain 2022-09-08 17:51:48 +02:00
parent 45bac88b26
commit c968f7b1aa
18 changed files with 260 additions and 178 deletions

View File

@ -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

View File

@ -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;
}
}

View File

@ -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); }
})}

View File

@ -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));
};

View File

@ -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"

View File

@ -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')}&nbsp;
<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>
);
};

View File

@ -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>
);
};

View File

@ -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 => {

View File

@ -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}`;
};
}

View File

@ -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>,
}

View File

@ -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

View File

@ -13,4 +13,8 @@ class ProductPolicy < ApplicationPolicy
def destroy?
user.privileged?
end
def stock_movements?
user.privileged?
end
end

View File

@ -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

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.extract! stock_movement, :id, :quantity, :reason, :stock_type, :remaining_stock, :date

View 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

View File

@ -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"

View File

@ -562,5 +562,5 @@ en:
main_image: "Main image"
store:
order_item:
total: "Total"
client: "Client"
total: "Total"
client: "Client"

View File

@ -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