1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-18 07:52:23 +01:00

(feat) products filtering for admin view

This commit is contained in:
Sylvain 2022-09-20 15:30:44 +02:00
parent 586dd5f9b5
commit a41e5a93e5
26 changed files with 425 additions and 272 deletions

View File

@ -9,7 +9,7 @@ Metrics/MethodLength:
Metrics/CyclomaticComplexity:
Max: 13
Metrics/PerceivedComplexity:
Max: 11
Max: 12
Metrics/AbcSize:
Max: 45
Metrics/ClassLength:

View File

@ -10,7 +10,6 @@ class API::ProductsController < API::ApiController
def index
@products = ProductService.list(params)
@pages = ProductService.pages(params) if params[:page].present?
end
def show

View File

@ -3,10 +3,11 @@ import { AxiosResponse } from 'axios';
import { serialize } from 'object-to-formdata';
import { Product, ProductIndexFilter, ProductsIndex, ProductStockMovement } from '../models/product';
import ApiLib from '../lib/api';
import ProductLib from '../lib/product';
export default class ProductAPI {
static async index (filters?: ProductIndexFilter): Promise<ProductsIndex> {
const res: AxiosResponse<ProductsIndex> = await apiClient.get(`/api/products${ApiLib.filtersToQuery(filters)}`);
const res: AxiosResponse<ProductsIndex> = await apiClient.get(`/api/products${ApiLib.filtersToQuery(ProductLib.indexFiltersToIds(filters))}`);
return res?.data;
}

View File

@ -0,0 +1,45 @@
import React from 'react';
import { ProductIndexFilter } from '../../../models/product';
import { X } from 'phosphor-react';
import { ProductCategory } from '../../../models/product-category';
import { Machine } from '../../../models/machine';
import { useTranslation } from 'react-i18next';
interface ActiveFiltersTagsProps {
filters: ProductIndexFilter,
onRemoveCategory: (category: ProductCategory) => void,
onRemoveMachine: (machine: Machine) => void,
onRemoveKeyword: () => void,
onRemoveStock: () => void,
}
/**
* Some tags listing the currently actives filters for a product list
*/
export const ActiveFiltersTags: React.FC<ActiveFiltersTagsProps> = ({ filters, onRemoveCategory, onRemoveMachine, onRemoveKeyword, onRemoveStock }) => {
const { t } = useTranslation('shared');
return (
<>
{filters.categories.map(c => (
<div key={c.id} className='features-item'>
<p>{c.name}</p>
<button onClick={() => onRemoveCategory(c)}><X size={16} weight="light" /></button>
</div>
))}
{filters.machines.map(m => (
<div key={m.id} className='features-item'>
<p>{m.name}</p>
<button onClick={() => onRemoveMachine(m)}><X size={16} weight="light" /></button>
</div>
))}
{filters.keywords[0] && <div className='features-item'>
<p>{filters.keywords[0]}</p>
<button onClick={onRemoveKeyword}><X size={16} weight="light" /></button>
</div>}
{(filters.stock_to !== 0 || filters.stock_from !== 0) && <div className='features-item'>
<p>{t(`app.shared.active_filters_tags.stock_${filters.stock_type}`)} [{filters.stock_from || '…'} {filters.stock_to || '…'}]</p>
<button onClick={onRemoveStock}><X size={16} weight="light" /></button>
</div>}
</>
);
};

View File

@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import _ from 'lodash';
import ProductCategoryAPI from '../../../api/product-category';
import ProductLib from '../../../lib/product';
import { ProductCategory } from '../../../models/product-category';
import { FabButton } from '../../base/fab-button';
@ -8,7 +7,7 @@ import { AccordionItem } from '../../base/accordion-item';
import { useTranslation } from 'react-i18next';
interface CategoriesFilterProps {
onError: (message: string) => void,
productCategories: Array<ProductCategory>,
onApplyFilters: (categories: Array<ProductCategory>) => void,
currentFilters: Array<ProductCategory>,
openDefault?: boolean,
@ -18,19 +17,12 @@ interface CategoriesFilterProps {
/**
* Component to filter the products list by categories
*/
export const CategoriesFilter: React.FC<CategoriesFilterProps> = ({ onError, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => {
export const CategoriesFilter: React.FC<CategoriesFilterProps> = ({ productCategories, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => {
const { t } = useTranslation('admin');
const [productCategories, setProductCategories] = useState<ProductCategory[]>([]);
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
const [selectedCategories, setSelectedCategories] = useState<ProductCategory[]>(currentFilters || []);
useEffect(() => {
ProductCategoryAPI.index().then(data => {
setProductCategories(ProductLib.sortCategories(data));
}).catch(onError);
}, []);
useEffect(() => {
if (currentFilters && !_.isEqual(currentFilters, selectedCategories)) {
setSelectedCategories(currentFilters);
@ -49,36 +41,7 @@ export const CategoriesFilter: React.FC<CategoriesFilterProps> = ({ onError, onA
* This may cause other categories to be selected or unselected accordingly.
*/
const handleSelectCategory = (currentCategory: ProductCategory, checked: boolean) => {
let list = [...selectedCategories];
const children = productCategories
.filter(el => el.parent_id === currentCategory.id);
const siblings = productCategories
.filter(el => el.parent_id === currentCategory.parent_id && el.parent_id !== null);
if (checked) {
list.push(currentCategory);
if (children.length) {
// if a parent category is selected, we automatically select all its children
list = [...Array.from(new Set([...list, ...children]))];
}
if (siblings.length && siblings.every(el => list.includes(el))) {
// if a child category is selected, with every sibling of it, we automatically select its parent
list.push(productCategories.find(p => p.id === siblings[0].parent_id));
}
} else {
list.splice(list.indexOf(currentCategory), 1);
const parent = productCategories.find(p => p.id === currentCategory.parent_id);
if (currentCategory.parent_id && list.includes(parent)) {
// if a child category is unselected, we unselect its parent
list.splice(list.indexOf(parent), 1);
}
if (children.length) {
// if a parent category is unselected, we unselect all its children
children.forEach(child => {
list.splice(list.indexOf(child), 1);
});
}
}
const list = ProductLib.categoriesSelectionTree(productCategories, selectedCategories, currentCategory, checked ? 'add' : 'remove');
setSelectedCategories(list);
if (instantUpdate) {

View File

@ -6,7 +6,7 @@ import _ from 'lodash';
interface KeywordFilterProps {
onApplyFilters: (keywork: string) => void,
currentFilters: string,
currentFilters?: string,
openDefault?: boolean,
instantUpdate?: boolean,
}
@ -14,14 +14,14 @@ interface KeywordFilterProps {
/**
* Component to filter the products list by keyword or product reference
*/
export const KeywordFilter: React.FC<KeywordFilterProps> = ({ onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => {
export const KeywordFilter: React.FC<KeywordFilterProps> = ({ onApplyFilters, currentFilters = '', openDefault = false, instantUpdate = false }) => {
const { t } = useTranslation('admin');
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
const [keyword, setKeyword] = useState<string>(currentFilters);
const [keyword, setKeyword] = useState<string>(currentFilters || '');
useEffect(() => {
if (currentFilters && !_.isEqual(currentFilters, keyword)) {
if (!_.isEqual(currentFilters, keyword)) {
setKeyword(currentFilters);
}
}, [currentFilters]);
@ -53,8 +53,8 @@ export const KeywordFilter: React.FC<KeywordFilterProps> = ({ onApplyFilters, cu
>
<div className="content">
<div className="group">
<input type="text" onChange={event => handleKeywordTyping(event)} />
<FabButton onClick={() => onApplyFilters(keyword)} className="is-info">{t('app.admin.store.keyword_filter.filter_apply')}</FabButton>
<input type="text" onChange={event => handleKeywordTyping(event)} value={keyword} />
<FabButton onClick={() => onApplyFilters(keyword || undefined)} className="is-info">{t('app.admin.store.keyword_filter.filter_apply')}</FabButton>
</div>
</div>
</AccordionItem>

View File

@ -47,7 +47,7 @@ export const MachinesFilter: React.FC<MachinesFilterProps> = ({ onError, onApply
* Callback triggered when a machine filter is seleced or unselected.
*/
const handleSelectMachine = (currentMachine: Machine, checked: boolean) => {
const list = [...machines];
const list = [...selectedMachines];
checked
? list.push(currentMachine)
: list.splice(list.indexOf(currentMachine), 1);
@ -63,13 +63,12 @@ export const MachinesFilter: React.FC<MachinesFilterProps> = ({ onError, onApply
<AccordionItem id={1}
isOpen={openedAccordion}
onChange={handleAccordion}
label={t('app.admin.store.machines_filter.filter_machines')}
>
label={t('app.admin.store.machines_filter.filter_machines')}>
<div className='content'>
<div className="group u-scrollbar">
{machines.map(m => (
<label key={m.id}>
<input type="checkbox" checked={machines.includes(m)} onChange={(event) => handleSelectMachine(m, event.target.checked)} />
<input type="checkbox" checked={selectedMachines.includes(m)} onChange={(event) => handleSelectMachine(m, event.target.checked)} />
<p>{m.name}</p>
</label>
))}

View File

@ -2,21 +2,15 @@ import React, { useEffect, useState } from 'react';
import { FabButton } from '../../base/fab-button';
import { AccordionItem } from '../../base/accordion-item';
import { useTranslation } from 'react-i18next';
import { StockType } from '../../../models/product';
import { ProductIndexFilter, StockType } from '../../../models/product';
import { FormSelect } from '../../form/form-select';
import { FormInput } from '../../form/form-input';
import { useForm } from 'react-hook-form';
import _ from 'lodash';
export interface StockFilterData {
stock_type: StockType,
stock_from: number,
stock_to: number
}
interface StockFilterProps {
onApplyFilters: (filters: StockFilterData) => void,
currentFilters: StockFilterData,
onApplyFilters: (filters: ProductIndexFilter) => void,
currentFilters: ProductIndexFilter,
openDefault?: boolean
}
@ -34,7 +28,7 @@ export const StockFilter: React.FC<StockFilterProps> = ({ onApplyFilters, curren
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
const { register, control, handleSubmit, getValues, reset } = useForm<StockFilterData>({ defaultValues: { ...currentFilters } });
const { register, control, handleSubmit, getValues, reset } = useForm<ProductIndexFilter>({ defaultValues: { ...currentFilters } });
useEffect(() => {
if (currentFilters && !_.isEqual(currentFilters, getValues())) {
@ -52,7 +46,7 @@ export const StockFilter: React.FC<StockFilterProps> = ({ onApplyFilters, curren
/**
* Callback triggered when the user clicks on "apply" to apply teh current filters.
*/
const onSubmit = (data: StockFilterData) => {
const onSubmit = (data: ProductIndexFilter) => {
onApplyFilters(data);
};

View File

@ -4,19 +4,21 @@ import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { Product } from '../../models/product';
import { Product, ProductIndexFilter, ProductsIndex } from '../../models/product';
import { ProductCategory } from '../../models/product-category';
import { FabButton } from '../base/fab-button';
import { ProductItem } from './product-item';
import ProductAPI from '../../api/product';
import { X } from 'phosphor-react';
import { StoreListHeader } from './store-list-header';
import { FabPagination } from '../base/fab-pagination';
import { CategoriesFilter } from './filters/categories-filter';
import { Machine } from '../../models/machine';
import { MachinesFilter } from './filters/machines-filter';
import { KeywordFilter } from './filters/keyword-filter';
import { StockFilter, StockFilterData } from './filters/stock-filter';
import { StockFilter } from './filters/stock-filter';
import ProductCategoryAPI from '../../api/product-category';
import ProductLib from '../../lib/product';
import { ActiveFiltersTags } from './filters/active-filters-tags';
declare const Application: IApplication;
@ -34,42 +36,57 @@ interface ProductsProps {
const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [filteredProductsList, setFilteredProductList] = useImmer<Array<Product>>([]);
const [features, setFeatures] = useImmer<Filters>(initFilters);
const [filterVisible, setFilterVisible] = useState<boolean>(false);
const [filters, setFilters] = useImmer<Filters>(initFilters);
const [clearFilters, setClearFilters] = useState<boolean>(false);
const [update, setUpdate] = useState(false);
const [productCategories, setProductCategories] = useState<Array<ProductCategory>>([]);
const [productsList, setProductList] = useState<Array<Product>>([]);
const [filters, setFilters] = useImmer<ProductIndexFilter>(initFilters);
const [pageCount, setPageCount] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(1);
const [productsCount, setProductsCount] = useState<number>(0);
useEffect(() => {
ProductAPI.index({ page: 1, is_active: filterVisible }).then(data => {
setPageCount(data.total_pages);
setFilteredProductList(data.products);
});
fetchProducts().then(scrollToProducts);
ProductCategoryAPI.index().then(data => {
setProductCategories(ProductLib.sortCategories(data));
}).catch(onError);
}, []);
useEffect(() => {
applyFilters();
setClearFilters(false);
setUpdate(false);
}, [filterVisible, clearFilters, update === true]);
fetchProducts().then(scrollToProducts);
}, [filters]);
/** Handle products pagination */
const handlePagination = (page: number) => {
if (page !== currentPage) {
ProductAPI.index({ page, is_active: filterVisible }).then(data => {
setCurrentPage(page);
setFilteredProductList(data.products);
setPageCount(data.total_pages);
window.document.getElementById('content-main').scrollTo({ top: 100, behavior: 'smooth' });
}).catch(() => {
onError(t('app.admin.store.products.unexpected_error_occurred'));
setFilters(draft => {
return { ...draft, page };
});
}
};
/**
* Fetch the products from the API, according to the current filters
*/
const fetchProducts = async (): Promise<ProductsIndex> => {
try {
const data = await ProductAPI.index(filters);
setCurrentPage(data.page);
setProductList(data.data);
setPageCount(data.total_pages);
setProductsCount(data.total_count);
return data;
} catch (error) {
onError(t('app.admin.store.products.unexpected_error_occurred'));
console.error(error);
}
};
/**
* Scroll the view to the product list
*/
const scrollToProducts = () => {
window.document.getElementById('content-main').scrollTo({ top: 100, behavior: 'smooth' });
};
/** Goto edit product page */
const editProduct = (product: Product) => {
window.location.href = `/#!/admin/store/products/${product.id}/edit`;
@ -79,8 +96,8 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
const deleteProduct = async (productId: number): Promise<void> => {
try {
await ProductAPI.destroy(productId);
const data = await ProductAPI.index();
setFilteredProductList(data.products);
await fetchProducts();
scrollToProducts();
onSuccess(t('app.admin.store.products.successfully_deleted'));
} catch (e) {
onError(t('app.admin.store.products.unable_to_delete') + e);
@ -94,8 +111,9 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
/** Filter: toggle non-available products visibility */
const toggleVisible = (checked: boolean) => {
setFilterVisible(!filterVisible);
console.log('Display on the shelf product only:', checked);
setFilters(draft => {
return { ...draft, is_active: checked };
});
};
/**
@ -107,6 +125,14 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
});
};
/**
* Remove the provided category from the filters selection
*/
const handleRemoveCategory = (category: ProductCategory) => {
const list = ProductLib.categoriesSelectionTree(productCategories, filters.categories, category, 'remove');
handleCategoriesFilterUpdate(list);
};
/**
* Update the list of applied filters with the given machines
*/
@ -126,7 +152,7 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
};
/** Filter: by stock range */
const handleStockFilterUpdate = (filters: StockFilterData) => {
const handleStockFilterUpdate = (filters: ProductIndexFilter) => {
setFilters(draft => {
return {
...draft,
@ -140,27 +166,9 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
console.log('Sort option:', option);
};
/** Apply filters */
const applyFilters = () => {
let tags = initFilters;
if (filters.categories.length) {
tags = { ...tags, categories: [...filters.categories] };
}
if (filters.machines.length) {
tags = { ...tags, machines: [...filters.machines] };
}
setFeatures(tags);
console.log('Apply filters:', filters);
};
/** Clear filters */
const clearAllFilters = () => {
setFilters(initFilters);
setClearFilters(true);
console.log('Clear all filters');
};
/** Creates sorting options to the react-select format */
@ -189,7 +197,7 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
</div>
</header>
<div className='accordion'>
<CategoriesFilter onError={onError}
<CategoriesFilter productCategories={productCategories}
onApplyFilters={handleCategoriesFilterUpdate}
currentFilters={filters.categories} />
@ -197,7 +205,7 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
onApplyFilters={handleMachinesFilterUpdate}
currentFilters={filters.machines} />
<KeywordFilter onApplyFilters={keyword => handleKeywordFilterUpdate([...filters.keywords, keyword])}
<KeywordFilter onApplyFilters={keyword => handleKeywordFilterUpdate([keyword])}
currentFilters={filters.keywords[0]} />
<StockFilter onApplyFilters={handleStockFilterUpdate}
@ -206,35 +214,22 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
</div>
<div className='store-list'>
<StoreListHeader
productsCount={filteredProductsList.length}
productsCount={productsCount}
selectOptions={buildSortOptions()}
onSelectOptionsChange={handleSorting}
switchChecked={filterVisible}
switchChecked={filters.is_active}
onSwitch={toggleVisible}
/>
<div className='features'>
{features.categories.map(c => (
<div key={c.id} className='features-item'>
<p>{c.name}</p>
<button onClick={() => handleCategoriesFilterUpdate(filters.categories.filter(cat => cat !== c))}><X size={16} weight="light" /></button>
</div>
))}
{features.machines.map(m => (
<div key={m.id} className='features-item'>
<p>{m.name}</p>
<button onClick={() => handleMachinesFilterUpdate(filters.machines.filter(machine => machine !== m))}><X size={16} weight="light" /></button>
</div>
))}
{features.keywords.map(k => (
<div key={k} className='features-item'>
<p>{k}</p>
<button onClick={() => handleKeywordFilterUpdate(filters.keywords.filter(keyword => keyword !== k))}><X size={16} weight="light" /></button>
</div>
))}
<ActiveFiltersTags filters={filters}
onRemoveCategory={handleRemoveCategory}
onRemoveMachine={(m) => handleMachinesFilterUpdate(filters.machines.filter(machine => machine !== m))}
onRemoveKeyword={() => handleKeywordFilterUpdate([])}
onRemoveStock={() => handleStockFilterUpdate({ stock_type: 'internal', stock_to: 0, stock_from: 0 })} />
</div>
<div className="products-list">
{filteredProductsList.map((product) => (
{productsList.map((product) => (
<ProductItem
key={product.id}
product={product}
@ -261,20 +256,13 @@ const ProductsWrapper: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError']));
interface Filters {
categories: ProductCategory[],
machines: Machine[],
keywords: string[],
stock_type: 'internal' | 'external',
stock_from: number,
stock_to: number
}
const initFilters: Filters = {
const initFilters: ProductIndexFilter = {
categories: [],
machines: [],
keywords: [],
stock_type: 'internal',
stock_from: 0,
stock_to: 0
stock_to: 0,
is_active: false,
page: 1
};

View File

@ -1,5 +1,10 @@
import { ProductCategory } from '../models/product-category';
import { stockMovementInReasons, stockMovementOutReasons, StockMovementReason } from '../models/product';
import {
ProductIndexFilter, ProductIndexFilterIds,
stockMovementInReasons,
stockMovementOutReasons,
StockMovementReason
} from '../models/product';
export default class ProductLib {
/**
@ -48,4 +53,53 @@ export default class ProductLib {
return `-${quantity}`;
}
};
/**
* Add or remove the given category from the given list;
* This may cause parent/children categories to be selected or unselected accordingly.
*/
static categoriesSelectionTree = (allCategories: Array<ProductCategory>, currentSelection: Array<ProductCategory>, category: ProductCategory, operation: 'add'|'remove'): Array<ProductCategory> => {
let list = [...currentSelection];
const children = allCategories
.filter(el => el.parent_id === category.id);
const siblings = allCategories
.filter(el => el.parent_id === category.parent_id && el.parent_id !== null);
if (operation === 'add') {
list.push(category);
if (children.length) {
// if a parent category is selected, we automatically select all its children
list = [...Array.from(new Set([...list, ...children]))];
}
if (siblings.length && siblings.every(el => list.includes(el))) {
// if a child category is selected, with every sibling of it, we automatically select its parent
list.push(allCategories.find(p => p.id === siblings[0].parent_id));
}
} else {
list.splice(list.indexOf(category), 1);
const parent = allCategories.find(p => p.id === category.parent_id);
if (category.parent_id && list.includes(parent)) {
// if a child category is unselected, we unselect its parent
list.splice(list.indexOf(parent), 1);
}
if (children.length) {
// if a parent category is unselected, we unselect all its children
children.forEach(child => {
list.splice(list.indexOf(child), 1);
});
}
}
return list;
};
/**
* Extract the IDS from the filters to pass them to the API
*/
static indexFiltersToIds = (filters: ProductIndexFilter): ProductIndexFilterIds => {
return {
...filters,
categories: filters.categories.map(c => c.id),
machines: filters.machines.map(m => m.id)
};
};
}

View File

@ -1,3 +1,10 @@
// ApiFilter should be extended by an interface listing all the filters allowed for a given API
// eslint-disable-next-line @typescript-eslint/ban-types
export type ApiFilter = {};
export type ApiFilter = Record<string, unknown>;
export interface PaginatedIndex<T> {
page: number,
total_pages: number,
page_size: number,
total_count: number,
data: Array<T>
}

View File

@ -3,6 +3,7 @@ import { PaymentConfirmation } from './payment';
import { CreateTokenResponse } from './payzen';
import { UserRole } from './user';
import { Coupon } from './coupon';
import { ApiFilter, PaginatedIndex } from './api';
export interface Order {
id: number,
@ -45,15 +46,9 @@ export interface OrderPayment {
payment?: PaymentConfirmation|CreateTokenResponse
}
export interface OrderIndex {
page: number,
total_pages: number,
page_size: number,
total_count: number,
data: Array<Order>
}
export type OrderIndex = PaginatedIndex<Order>;
export interface OrderIndexFilter {
export interface OrderIndexFilter extends ApiFilter {
reference?: string,
user_id?: number,
user?: {

View File

@ -1,9 +1,22 @@
import { TDateISO } from '../typings/date-iso';
import { ApiFilter } from './api';
import { ApiFilter, PaginatedIndex } from './api';
import { ProductCategory } from './product-category';
import { Machine } from './machine';
export interface ProductIndexFilter extends ApiFilter {
export interface ProductIndexFilter {
is_active?: boolean,
page?: number
page?: number,
categories?: ProductCategory[],
machines?: Machine[],
keywords?: string[],
stock_type?: 'internal' | 'external',
stock_from?: number,
stock_to?: number,
}
export interface ProductIndexFilterIds extends Omit<Omit<ProductIndexFilter, 'categories'>, 'machines'>, ApiFilter {
categories?: Array<number>,
machines?: Array<number>,
}
export type StockType = 'internal' | 'external' | 'all';
@ -19,10 +32,7 @@ export interface Stock {
external: number,
}
export interface ProductsIndex {
total_pages?: number,
products: Array<Product>
}
export type ProductsIndex = PaginatedIndex<Product>;
export interface ProductStockMovement {
id?: number,

View File

@ -10,12 +10,14 @@ class Machine < ApplicationRecord
has_many :machine_files, as: :viewable, dependent: :destroy
accepts_nested_attributes_for :machine_files, allow_destroy: true, reject_if: :all_blank
has_and_belongs_to_many :projects, join_table: 'projects_machines'
has_many :projects_machines, dependent: :destroy
has_many :projects, through: :projects_machines
has_many :machines_availabilities, dependent: :destroy
has_many :availabilities, through: :machines_availabilities
has_and_belongs_to_many :trainings, join_table: 'trainings_machines'
has_many :trainings_machines, dependent: :destroy
has_many :trainings, through: :trainings_machines
validates :name, presence: true, length: { maximum: 50 }
validates :description, presence: true
@ -27,9 +29,10 @@ class Machine < ApplicationRecord
has_many :credits, as: :creditable, dependent: :destroy
has_many :plans, through: :credits
has_one :payment_gateway_object, as: :item
has_one :payment_gateway_object, as: :item, dependent: :destroy
has_and_belongs_to_many :products
has_many :machines_products, dependent: :destroy
has_many :products, through: :machines_products
after_create :create_statistic_subtype
after_create :create_machine_prices
@ -66,11 +69,11 @@ class Machine < ApplicationRecord
end
def create_machine_prices
Group.all.each do |group|
Group.find_each do |group|
Price.create(priceable: self, group: group, amount: 0)
end
Plan.all.includes(:group).each do |plan|
Plan.includes(:group).find_each do |plan|
Price.create(group: plan.group, plan: plan, priceable: self, amount: 0)
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
# MachinesAvailability is the relation table between a Machine and a Product.
class MachinesProduct < ApplicationRecord
belongs_to :machine
belongs_to :product
end

View File

@ -8,7 +8,8 @@ class Product < ApplicationRecord
belongs_to :product_category
has_and_belongs_to_many :machines
has_many :machines_products, dependent: :destroy
has_many :machines, through: :machines_products
has_many :product_files, as: :viewable, dependent: :destroy
accepts_nested_attributes_for :product_files, allow_destroy: true, reject_if: :all_blank

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
# ProjectsMachine is the relation table between a Machine and a Project.
class ProjectsMachine < ApplicationRecord
belongs_to :machine
belongs_to :project
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
# TrainingsMachine is the relation table between a Machine and a Training.
class TrainingsMachine < ApplicationRecord
belongs_to :machine
belongs_to :training
end

View File

@ -1,19 +1,19 @@
# frozen_string_literal: true
# Provides methods for pay cart
# Provides methods to pay the cart
class Checkout::PaymentService
require 'pay_zen/helper'
require 'stripe/helper'
include Payments::PaymentConcern
def payment(order, operator, coupon_code, payment_id = '')
raise Cart::InactiveProductError unless Orders::OrderService.new.all_products_is_active?(order)
raise Cart::InactiveProductError unless Orders::OrderService.all_products_is_active?(order)
raise Cart::OutStockError unless Orders::OrderService.new.in_stock?(order, 'external')
raise Cart::OutStockError unless Orders::OrderService.in_stock?(order, 'external')
raise Cart::QuantityMinError unless Orders::OrderService.new.greater_than_quantity_min?(order)
raise Cart::QuantityMinError unless Orders::OrderService.greater_than_quantity_min?(order)
raise Cart::ItemAmountError unless Orders::OrderService.new.item_amount_not_equal?(order)
raise Cart::ItemAmountError unless Orders::OrderService.item_amount_not_equal?(order)
CouponService.new.validate(coupon_code, order.statistic_profile.user.id)

View File

@ -2,33 +2,15 @@
# Provides methods for Order
class Orders::OrderService
class << self
ORDERS_PER_PAGE = 20
def self.list(filters, current_user)
def list(filters, current_user)
orders = Order.where(nil)
if filters[:user_id]
statistic_profile_id = current_user.statistic_profile.id
if (current_user.member? && current_user.id == filters[:user_id].to_i) || current_user.privileged?
user = User.find(filters[:user_id])
statistic_profile_id = user.statistic_profile.id
end
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(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 = filter_by_user(orders, filters, current_user)
orders = filter_by_reference(orders, filters, current_user)
orders = filter_by_state(orders, filters)
orders = filter_by_period(orders, filters)
orders = orders.where.not(state: 'cart') if current_user.member?
orders = orders.order(created_at: filters[:sort] || 'DESC')
@ -36,19 +18,28 @@ class Orders::OrderService
orders = orders.page(filters[:page] || 1).per(ORDERS_PER_PAGE)
{
data: orders,
page: filters[:page] || 1,
page: filters[:page]&.to_i || 1,
total_pages: orders.page(1).per(ORDERS_PER_PAGE).total_pages,
page_size: ORDERS_PER_PAGE,
total_count: total_count
}
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::OrderCanceledService.new.call(order, current_user) if state == 'canceled'
return ::Orders::OrderDeliveredService.new.call(order, current_user) if state == 'delivered'
return ::Orders::OrderRefundedService.new.call(order, current_user) if state == 'refunded'
def update_state(order, current_user, state, note = nil)
case state
when 'in_progress'
::Orders::SetInProgressService.new.call(order, current_user)
when 'ready'
::Orders::OrderReadyService.new.call(order, current_user, note)
when 'canceled'
::Orders::OrderCanceledService.new.call(order, current_user)
when 'delivered'
::Orders::OrderDeliveredService.new.call(order, current_user)
when 'refunded'
::Orders::OrderRefundedService.new.call(order, current_user)
else
nil
end
end
def in_stock?(order, stock_type = 'external')
@ -78,4 +69,42 @@ class Orders::OrderService
end
true
end
private
def filter_by_user(orders, filters, current_user)
if filters[:user_id]
statistic_profile_id = current_user.statistic_profile.id
if (current_user.member? && current_user.id == filters[:user_id].to_i) || current_user.privileged?
user = User.find(filters[:user_id])
statistic_profile_id = user.statistic_profile.id
end
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
end
def filter_by_reference(orders, filters, current_user)
return orders unless filters[:reference].present? && current_user.privileged?
orders.where(reference: filters[:reference])
end
def filter_by_state(orders, filters)
return orders if filters[:states].blank?
state = filters[:states].split(',')
orders.where(state: state) unless state.empty?
end
def filter_by_period(orders, filters)
return orders unless filters[:period_from].present? && filters[:period_to].present?
orders.where(created_at: DateTime.parse(filters[:period_from])..DateTime.parse(filters[:period_to]).end_of_day)
end
end
end

View File

@ -7,21 +7,21 @@ class ProductService
def list(filters)
products = Product.includes(:product_images)
if filters[:is_active].present?
state = filters[:disabled] == 'false' ? [nil, false] : true
products = products.where(is_active: state)
end
products = products.page(filters[:page]).per(PRODUCTS_PER_PAGE) if filters[:page].present?
products
end
products = filter_by_active(products, filters)
products = filter_by_categories(products, filters)
products = filter_by_machines(products, filters)
products = filter_by_keyword_or_reference(products, filters)
products = filter_by_stock(products, filters)
def pages(filters)
products = Product.all
if filters[:is_active].present?
state = filters[:disabled] == 'false' ? [nil, false] : true
products = Product.where(is_active: state)
end
products.page(1).per(PRODUCTS_PER_PAGE).total_pages
total_count = products.count
products = products.page(filters[:page] || 1).per(PRODUCTS_PER_PAGE)
{
data: products,
page: filters[:page]&.to_i || 1,
total_pages: products.page(1).per(PRODUCTS_PER_PAGE).total_pages,
page_size: PRODUCTS_PER_PAGE,
total_count: total_count
}
end
# amount params multiplied by hundred
@ -80,5 +80,40 @@ class ProductService
product.destroy
end
end
private
def filter_by_active(products, filters)
return products if filters[:is_active].blank?
state = filters[:is_active] == 'false' ? [nil, false, true] : true
products.where(is_active: state)
end
def filter_by_categories(products, filters)
return products if filters[:categories].blank?
products.where(product_category_id: filters[:categories].split(','))
end
def filter_by_machines(products, filters)
return products if filters[:machines].blank?
products.includes(:machines_products).where('machines_products.machine_id': filters[:machines].split(','))
end
def filter_by_keyword_or_reference(products, filters)
return products if filters[:keywords].blank?
products.where('sku = :sku OR name ILIKE :query OR description ILIKE :query',
{ sku: filters[:keywords], query: "%#{filters[:keywords]}%" })
end
def filter_by_stock(products, filters)
products.where("(stock ->> '#{filters[:stock_type]}')::int >= #{filters[:stock_from]}") if filters[:stock_from].to_i.positive?
products.where("(stock ->> '#{filters[:stock_type]}')::int <= #{filters[:stock_to]}") if filters[:stock_to].to_i.positive?
products
end
end
end

View File

@ -1,9 +1,6 @@
# frozen_string_literal: true
json.page @result[:page]
json.total_pages @result[:total_pages]
json.page_size @result[:page_size]
json.total_count @result[:total_count]
json.extract! @result, :page, :total_pages, :page_size, :total_count
json.data @result[:data] do |order|
json.extract! order, :id, :statistic_profile_id, :reference, :state, :created_at, :updated_at
json.total order.total / 100.0 if order.total.present?

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
json.total_pages @pages if @pages.present?
json.products @products do |product|
json.extract! @products, :page, :total_pages, :page_size, :total_count
json.data @products[:data] do |product|
json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :machine_ids,
:low_stock_threshold
json.amount product.amount / 100.0 if product.amount.present?

View File

@ -636,3 +636,6 @@ en:
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"
active_filters_tags:
stock_internal: "Private stock"
stock_external: "Public stock"

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
# Products' slugs should validate uniqness in database
class AddIndexOnProductSlug < ActiveRecord::Migration[5.2]
def change
add_index :products, :slug, unique: true
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_09_15_133100) do
ActiveRecord::Schema.define(version: 2022_09_20_131912) do
# These are extensions that must be enabled in order to support this database
enable_extension "fuzzystrmatch"
@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
enable_extension "unaccent"
create_table "abuses", id: :serial, force: :cascade do |t|
t.string "signaled_type"
t.integer "signaled_id"
t.string "signaled_type"
t.string "first_name"
t.string "last_name"
t.string "email"
@ -49,8 +49,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
t.string "locality"
t.string "country"
t.string "postal_code"
t.string "placeable_type"
t.integer "placeable_id"
t.string "placeable_type"
t.datetime "created_at"
t.datetime "updated_at"
end
@ -64,8 +64,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
end
create_table "assets", id: :serial, force: :cascade do |t|
t.string "viewable_type"
t.integer "viewable_id"
t.string "viewable_type"
t.string "attachment"
t.string "type"
t.datetime "created_at"
@ -147,8 +147,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
end
create_table "credits", id: :serial, force: :cascade do |t|
t.string "creditable_type"
t.integer "creditable_id"
t.string "creditable_type"
t.integer "plan_id"
t.integer "hours"
t.datetime "created_at"
@ -375,15 +375,15 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
create_table "notifications", id: :serial, force: :cascade do |t|
t.integer "receiver_id"
t.string "attached_object_type"
t.integer "attached_object_id"
t.string "attached_object_type"
t.integer "notification_type_id"
t.boolean "is_read", default: false
t.datetime "created_at"
t.datetime "updated_at"
t.string "receiver_type"
t.boolean "is_send", default: false
t.jsonb "meta_data", default: "{}"
t.jsonb "meta_data", default: {}
t.index ["notification_type_id"], name: "index_notifications_on_notification_type_id"
t.index ["receiver_id"], name: "index_notifications_on_receiver_id"
end
@ -623,8 +623,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
create_table "prices", id: :serial, force: :cascade do |t|
t.integer "group_id"
t.integer "plan_id"
t.string "priceable_type"
t.integer "priceable_id"
t.string "priceable_type"
t.integer "amount"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -672,6 +672,7 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["product_category_id"], name: "index_products_on_product_category_id"
t.index ["slug"], name: "index_products_on_slug", unique: true
end
create_table "profile_custom_fields", force: :cascade do |t|
@ -822,8 +823,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
t.text "message"
t.datetime "created_at"
t.datetime "updated_at"
t.string "reservable_type"
t.integer "reservable_id"
t.string "reservable_type"
t.integer "nb_reserve_places"
t.integer "statistic_profile_id"
t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_id"
@ -832,8 +833,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
create_table "roles", id: :serial, force: :cascade do |t|
t.string "name"
t.string "resource_type"
t.integer "resource_id"
t.string "resource_type"
t.datetime "created_at"
t.datetime "updated_at"
t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id"
@ -1113,8 +1114,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
t.boolean "is_allow_newsletter"
t.inet "current_sign_in_ip"
t.inet "last_sign_in_ip"
t.datetime "validated_at"
t.string "mapped_from_sso"
t.datetime "validated_at"
t.index ["auth_token"], name: "index_users_on_auth_token"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true