mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-02-21 15:54:22 +01:00
(feat) filter and paginate stock movements
This commit is contained in:
parent
3cb70c5cf0
commit
d750885f3b
@ -6,8 +6,6 @@ class API::ProductsController < API::ApiController
|
|||||||
before_action :authenticate_user!, except: %i[index show]
|
before_action :authenticate_user!, except: %i[index show]
|
||||||
before_action :set_product, only: %i[update destroy]
|
before_action :set_product, only: %i[update destroy]
|
||||||
|
|
||||||
MOVEMENTS_PER_PAGE = 10
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@products = ProductService.list(params, current_user)
|
@products = ProductService.list(params, current_user)
|
||||||
end
|
end
|
||||||
@ -45,7 +43,7 @@ class API::ProductsController < API::ApiController
|
|||||||
|
|
||||||
def stock_movements
|
def stock_movements
|
||||||
authorize Product
|
authorize Product
|
||||||
@movements = ProductStockMovement.where(product_id: params[:id]).order(date: :desc).page(params[:page]).per(MOVEMENTS_PER_PAGE)
|
@movements = ProductService.stock_movements(params)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -1,7 +1,12 @@
|
|||||||
import apiClient from './clients/api-client';
|
import apiClient from './clients/api-client';
|
||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import { serialize } from 'object-to-formdata';
|
import { serialize } from 'object-to-formdata';
|
||||||
import { Product, ProductIndexFilter, ProductsIndex, ProductStockMovement } from '../models/product';
|
import {
|
||||||
|
Product,
|
||||||
|
ProductIndexFilter,
|
||||||
|
ProductsIndex,
|
||||||
|
StockMovementIndex, StockMovementIndexFilter
|
||||||
|
} from '../models/product';
|
||||||
import ApiLib from '../lib/api';
|
import ApiLib from '../lib/api';
|
||||||
import ProductLib from '../lib/product';
|
import ProductLib from '../lib/product';
|
||||||
|
|
||||||
@ -91,8 +96,8 @@ export default class ProductAPI {
|
|||||||
return res?.data;
|
return res?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async stockMovements (productId: number, page = 1): Promise<Array<ProductStockMovement>> {
|
static async stockMovements (productId: number, filters: StockMovementIndexFilter): Promise<StockMovementIndex> {
|
||||||
const res: AxiosResponse<Array<ProductStockMovement>> = await apiClient.get(`/api/products/${productId}/stock_movements?page=${page}`);
|
const res: AxiosResponse<StockMovementIndex> = await apiClient.get(`/api/products/${productId}/stock_movements${ApiLib.filtersToQuery(filters)}`);
|
||||||
return res?.data;
|
return res?.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,29 +14,29 @@ export const FabPagination: React.FC<FabPaginationProps> = ({ pageCount, current
|
|||||||
return (
|
return (
|
||||||
<nav className='fab-pagination'>
|
<nav className='fab-pagination'>
|
||||||
{currentPage - 2 > 1 &&
|
{currentPage - 2 > 1 &&
|
||||||
<button onClick={() => selectPage(1)}><CaretDoubleLeft size={24} /></button>
|
<button type="button" onClick={() => selectPage(1)}><CaretDoubleLeft size={24} /></button>
|
||||||
}
|
}
|
||||||
{currentPage - 1 >= 1 &&
|
{currentPage - 1 >= 1 &&
|
||||||
<button onClick={() => selectPage(currentPage - 1)}><CaretLeft size={24} /></button>
|
<button type="button" onClick={() => selectPage(currentPage - 1)}><CaretLeft size={24} /></button>
|
||||||
}
|
}
|
||||||
{currentPage - 2 >= 1 &&
|
{currentPage - 2 >= 1 &&
|
||||||
<button onClick={() => selectPage(currentPage - 2)}>{currentPage - 2}</button>
|
<button type="button" onClick={() => selectPage(currentPage - 2)}>{currentPage - 2}</button>
|
||||||
}
|
}
|
||||||
{currentPage - 1 >= 1 &&
|
{currentPage - 1 >= 1 &&
|
||||||
<button onClick={() => selectPage(currentPage - 1)}>{currentPage - 1}</button>
|
<button type="button" onClick={() => selectPage(currentPage - 1)}>{currentPage - 1}</button>
|
||||||
}
|
}
|
||||||
<button className='is-active'>{currentPage}</button>
|
<button type="button" className='is-active'>{currentPage}</button>
|
||||||
{currentPage + 1 <= pageCount &&
|
{currentPage + 1 <= pageCount &&
|
||||||
<button onClick={() => selectPage(currentPage + 1)}>{currentPage + 1}</button>
|
<button type="button" onClick={() => selectPage(currentPage + 1)}>{currentPage + 1}</button>
|
||||||
}
|
}
|
||||||
{currentPage + 2 <= pageCount &&
|
{currentPage + 2 <= pageCount &&
|
||||||
<button onClick={() => selectPage(currentPage + 2)}>{currentPage + 2}</button>
|
<button type="button" onClick={() => selectPage(currentPage + 2)}>{currentPage + 2}</button>
|
||||||
}
|
}
|
||||||
{currentPage + 1 <= pageCount &&
|
{currentPage + 1 <= pageCount &&
|
||||||
<button onClick={() => selectPage(currentPage + 1)}><CaretRight size={24} /></button>
|
<button type="button" onClick={() => selectPage(currentPage + 1)}><CaretRight size={24} /></button>
|
||||||
}
|
}
|
||||||
{currentPage + 2 < pageCount &&
|
{currentPage + 2 < pageCount &&
|
||||||
<button onClick={() => selectPage(pageCount)}><CaretDoubleRight size={24} /></button>
|
<button type="button" onClick={() => selectPage(pageCount)}><CaretDoubleRight size={24} /></button>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
@ -4,7 +4,12 @@ import { PencilSimple, X } from 'phosphor-react';
|
|||||||
import { useFieldArray, UseFormRegister } from 'react-hook-form';
|
import { useFieldArray, UseFormRegister } from 'react-hook-form';
|
||||||
import { Control, FormState, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
import { Control, FormState, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Product, ProductStockMovement, StockMovementReason, StockType } from '../../models/product';
|
import {
|
||||||
|
Product,
|
||||||
|
stockMovementAllReasons, StockMovementIndex, StockMovementIndexFilter,
|
||||||
|
StockMovementReason,
|
||||||
|
StockType
|
||||||
|
} from '../../models/product';
|
||||||
import { HtmlTranslate } from '../base/html-translate';
|
import { HtmlTranslate } from '../base/html-translate';
|
||||||
import { FormSwitch } from '../form/form-switch';
|
import { FormSwitch } from '../form/form-switch';
|
||||||
import { FormInput } from '../form/form-input';
|
import { FormInput } from '../form/form-input';
|
||||||
@ -15,6 +20,8 @@ import { FabStateLabel } from '../base/fab-state-label';
|
|||||||
import ProductAPI from '../../api/product';
|
import ProductAPI from '../../api/product';
|
||||||
import FormatLib from '../../lib/format';
|
import FormatLib from '../../lib/format';
|
||||||
import ProductLib from '../../lib/product';
|
import ProductLib from '../../lib/product';
|
||||||
|
import { useImmer } from 'use-immer';
|
||||||
|
import { FabPagination } from '../base/fab-pagination';
|
||||||
|
|
||||||
interface ProductStockFormProps<TContext extends object> {
|
interface ProductStockFormProps<TContext extends object> {
|
||||||
currentFormValues: Product,
|
currentFormValues: Product,
|
||||||
@ -37,15 +44,16 @@ export const ProductStockForm = <TContext extends object> ({ currentFormValues,
|
|||||||
const [activeThreshold, setActiveThreshold] = useState<boolean>(currentFormValues.low_stock_threshold != null);
|
const [activeThreshold, setActiveThreshold] = useState<boolean>(currentFormValues.low_stock_threshold != null);
|
||||||
// is the update stock modal open?
|
// is the update stock modal open?
|
||||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
const [stockMovements, setStockMovements] = useState<Array<ProductStockMovement>>([]);
|
const [stockMovements, setStockMovements] = useState<StockMovementIndex>(null);
|
||||||
|
const [filters, setFilters] = useImmer<StockMovementIndexFilter>({ page: 1 });
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({ control, name: 'product_stock_movements_attributes' });
|
const { fields, append, remove } = useFieldArray({ control, name: 'product_stock_movements_attributes' });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentFormValues?.id) return;
|
if (!currentFormValues?.id) return;
|
||||||
|
|
||||||
ProductAPI.stockMovements(currentFormValues.id).then(setStockMovements).catch(onError);
|
ProductAPI.stockMovements(currentFormValues.id, filters).then(setStockMovements).catch(onError);
|
||||||
}, []);
|
}, [filters]);
|
||||||
|
|
||||||
// Styles the React-select component
|
// Styles the React-select component
|
||||||
const customStyles = {
|
const customStyles = {
|
||||||
@ -65,7 +73,7 @@ export const ProductStockForm = <TContext extends object> ({ currentFormValues,
|
|||||||
* Creates sorting options to the react-select format
|
* Creates sorting options to the react-select format
|
||||||
*/
|
*/
|
||||||
const buildReasonsOptions = (): Array<reasonSelectOption> => {
|
const buildReasonsOptions = (): Array<reasonSelectOption> => {
|
||||||
return (['inward_stock', 'returned', 'cancelled', 'inventory_fix', 'sold', 'missing', 'damaged'] as Array<StockMovementReason>).map(key => {
|
return stockMovementAllReasons.map(key => {
|
||||||
return { value: key, label: t(ProductLib.stockMovementReasonTrKey(key)) };
|
return { value: key, label: t(ProductLib.stockMovementReasonTrKey(key)) };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -83,16 +91,38 @@ export const ProductStockForm = <TContext extends object> ({ currentFormValues,
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On events option change
|
* On stock movement reason filter change
|
||||||
*/
|
*/
|
||||||
const eventsOptionsChange = (evt: reasonSelectOption) => {
|
const eventsOptionsChange = (evt: reasonSelectOption) => {
|
||||||
console.log('Event option:', evt);
|
setFilters(draft => {
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
reason: evt.value
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* On stocks option change
|
* On stocks type filter change
|
||||||
*/
|
*/
|
||||||
const stocksOptionsChange = (evt: typeSelectOption) => {
|
const stocksOptionsChange = (evt: typeSelectOption) => {
|
||||||
console.log('Stocks option:', evt);
|
setFilters(draft => {
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
stock_type: evt.value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the user wants to swich the current page of stock movements
|
||||||
|
*/
|
||||||
|
const handlePagination = (page: number) => {
|
||||||
|
setFilters(draft => {
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
page
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -124,8 +154,8 @@ export const ProductStockForm = <TContext extends object> ({ currentFormValues,
|
|||||||
* Return the data of the update of the stock for the current product
|
* Return the data of the update of the stock for the current product
|
||||||
*/
|
*/
|
||||||
const lastStockUpdate = () => {
|
const lastStockUpdate = () => {
|
||||||
if (stockMovements[0]) {
|
if (stockMovements?.data[0]) {
|
||||||
return stockMovements[0].date;
|
return stockMovements?.data[0].date;
|
||||||
} else {
|
} else {
|
||||||
return currentFormValues?.created_at || new Date();
|
return currentFormValues?.created_at || new Date();
|
||||||
}
|
}
|
||||||
@ -235,7 +265,7 @@ export const ProductStockForm = <TContext extends object> ({ currentFormValues,
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{stockMovements.map(movement => <div className="stock-history" key={movement.id}>
|
{stockMovements?.data?.map(movement => <div className="stock-history" key={movement.id}>
|
||||||
<div className="stock-item">
|
<div className="stock-item">
|
||||||
<p className='title'>{currentFormValues.name}</p>
|
<p className='title'>{currentFormValues.name}</p>
|
||||||
<p>{FormatLib.date(movement.date)}</p>
|
<p>{FormatLib.date(movement.date)}</p>
|
||||||
@ -253,6 +283,11 @@ export const ProductStockForm = <TContext extends object> ({ currentFormValues,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
{stockMovements?.total_pages > 1 &&
|
||||||
|
<FabPagination pageCount={stockMovements.total_pages}
|
||||||
|
currentPage={stockMovements.page}
|
||||||
|
selectPage={handlePagination} />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<ProductStockModal onSuccess={onNewStockMovement}
|
<ProductStockModal onSuccess={onNewStockMovement}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
@ -89,6 +89,14 @@ export interface ProductStockMovement {
|
|||||||
date?: TDateISO
|
date?: TDateISO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StockMovementIndex = PaginatedIndex<ProductStockMovement>;
|
||||||
|
|
||||||
|
export interface StockMovementIndexFilter extends ApiFilter {
|
||||||
|
reason?: StockMovementReason,
|
||||||
|
stock_type?: StockType,
|
||||||
|
page?: number,
|
||||||
|
}
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id?: number,
|
id?: number,
|
||||||
name: string,
|
name: string,
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
class ProductService
|
class ProductService
|
||||||
class << self
|
class << self
|
||||||
PRODUCTS_PER_PAGE = 12
|
PRODUCTS_PER_PAGE = 12
|
||||||
|
MOVEMENTS_PER_PAGE = 10
|
||||||
|
|
||||||
def list(filters, operator)
|
def list(filters, operator)
|
||||||
products = Product.includes(:product_images)
|
products = Product.includes(:product_images)
|
||||||
@ -88,6 +89,22 @@ class ProductService
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def stock_movements(filters)
|
||||||
|
movements = ProductStockMovement.where(product_id: filters[:id]).order(date: :desc)
|
||||||
|
movements = filter_by_stock_type(movements, filters)
|
||||||
|
movements = filter_by_reason(movements, filters)
|
||||||
|
|
||||||
|
total_count = movements.count
|
||||||
|
movements = movements.page(filters[:page] || 1).per(MOVEMENTS_PER_PAGE)
|
||||||
|
{
|
||||||
|
data: movements,
|
||||||
|
page: filters[:page]&.to_i || 1,
|
||||||
|
total_pages: movements.page(1).per(MOVEMENTS_PER_PAGE).total_pages,
|
||||||
|
page_size: MOVEMENTS_PER_PAGE,
|
||||||
|
total_count: total_count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def filter_by_active(products, filters)
|
def filter_by_active(products, filters)
|
||||||
@ -144,5 +161,17 @@ class ProductService
|
|||||||
products.order(key => order)
|
products.order(key => order)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def filter_by_stock_type(movements, filters)
|
||||||
|
return movements if filters[:stock_type].blank? || filters[:stock_type] == 'all'
|
||||||
|
|
||||||
|
movements.where(stock_type: filters[:stock_type])
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_by_reason(movements, filters)
|
||||||
|
return movements if filters[:reason].blank?
|
||||||
|
|
||||||
|
movements.where(reason: filters[:reason])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
json.array! @movements do |movement|
|
json.extract! @movements, :page, :total_pages, :page_size, :total_count
|
||||||
|
json.data @movements[:data] do |movement|
|
||||||
json.partial! 'api/products/stock_movement', stock_movement: movement
|
json.partial! 'api/products/stock_movement', stock_movement: movement
|
||||||
json.extract! movement, :product_id
|
json.extract! movement, :product_id
|
||||||
end
|
end
|
||||||
|
@ -2046,6 +2046,7 @@ en:
|
|||||||
type_in: "Add"
|
type_in: "Add"
|
||||||
type_out: "Remove"
|
type_out: "Remove"
|
||||||
cancel: "Cancel this operation"
|
cancel: "Cancel this operation"
|
||||||
|
load_more: "Load more"
|
||||||
product_stock_modal:
|
product_stock_modal:
|
||||||
modal_title: "Manage stock"
|
modal_title: "Manage stock"
|
||||||
internal: "Private stock"
|
internal: "Private stock"
|
||||||
@ -2065,8 +2066,8 @@ en:
|
|||||||
sold: "Sold"
|
sold: "Sold"
|
||||||
missing: "Missing in stock"
|
missing: "Missing in stock"
|
||||||
damaged: "Damaged product"
|
damaged: "Damaged product"
|
||||||
other_in: "Other"
|
other_in: "Other (in)"
|
||||||
other_out: "Other"
|
other_out: "Other (out)"
|
||||||
orders:
|
orders:
|
||||||
heading: "Orders"
|
heading: "Orders"
|
||||||
create_order: "Create an order"
|
create_order: "Create an order"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user