mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2024-12-01 12:24:28 +01:00
(merge) branch 'origin/product-store_filtering'
This commit is contained in:
commit
e4c0c10ef3
@ -9,7 +9,7 @@ Metrics/MethodLength:
|
|||||||
Metrics/CyclomaticComplexity:
|
Metrics/CyclomaticComplexity:
|
||||||
Max: 13
|
Max: 13
|
||||||
Metrics/PerceivedComplexity:
|
Metrics/PerceivedComplexity:
|
||||||
Max: 11
|
Max: 12
|
||||||
Metrics/AbcSize:
|
Metrics/AbcSize:
|
||||||
Max: 45
|
Max: 45
|
||||||
Metrics/ClassLength:
|
Metrics/ClassLength:
|
||||||
|
@ -10,7 +10,6 @@ class API::ProductsController < API::ApiController
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
@products = ProductService.list(params)
|
@products = ProductService.list(params)
|
||||||
@pages = ProductService.pages(params) if params[:page].present?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
@ -3,10 +3,11 @@ 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, ProductStockMovement } from '../models/product';
|
||||||
import ApiLib from '../lib/api';
|
import ApiLib from '../lib/api';
|
||||||
|
import ProductLib from '../lib/product';
|
||||||
|
|
||||||
export default class ProductAPI {
|
export default class ProductAPI {
|
||||||
static async index (filters?: ProductIndexFilter): Promise<ProductsIndex> {
|
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;
|
return res?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable fabmanager/scoped-translation */
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { CaretDown } from 'phosphor-react';
|
import { CaretDown } from 'phosphor-react';
|
||||||
|
|
||||||
@ -14,6 +13,7 @@ interface AccordionItemProps {
|
|||||||
*/
|
*/
|
||||||
export const AccordionItem: React.FC<AccordionItemProps> = ({ isOpen, onChange, id, label, children }) => {
|
export const AccordionItem: React.FC<AccordionItemProps> = ({ isOpen, onChange, id, label, children }) => {
|
||||||
const [state, setState] = useState(isOpen);
|
const [state, setState] = useState(isOpen);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onChange(id, state);
|
onChange(id, state);
|
||||||
}, [state]);
|
}, [state]);
|
@ -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>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,72 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import ProductLib from '../../../lib/product';
|
||||||
|
import { ProductCategory } from '../../../models/product-category';
|
||||||
|
import { FabButton } from '../../base/fab-button';
|
||||||
|
import { AccordionItem } from '../../base/accordion-item';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface CategoriesFilterProps {
|
||||||
|
productCategories: Array<ProductCategory>,
|
||||||
|
onApplyFilters: (categories: Array<ProductCategory>) => void,
|
||||||
|
currentFilters: Array<ProductCategory>,
|
||||||
|
openDefault?: boolean,
|
||||||
|
instantUpdate?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to filter the products list by categories
|
||||||
|
*/
|
||||||
|
export const CategoriesFilter: React.FC<CategoriesFilterProps> = ({ productCategories, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
|
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState<ProductCategory[]>(currentFilters || []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentFilters && !_.isEqual(currentFilters, selectedCategories)) {
|
||||||
|
setSelectedCategories(currentFilters);
|
||||||
|
}
|
||||||
|
}, [currentFilters]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open/close the accordion item
|
||||||
|
*/
|
||||||
|
const handleAccordion = (id, state: boolean) => {
|
||||||
|
setOpenedAccordion(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when a category filter is selected or unselected.
|
||||||
|
* This may cause other categories to be selected or unselected accordingly.
|
||||||
|
*/
|
||||||
|
const handleSelectCategory = (currentCategory: ProductCategory, checked: boolean) => {
|
||||||
|
const list = ProductLib.categoriesSelectionTree(productCategories, selectedCategories, currentCategory, checked ? 'add' : 'remove');
|
||||||
|
|
||||||
|
setSelectedCategories(list);
|
||||||
|
if (instantUpdate) {
|
||||||
|
onApplyFilters(list);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AccordionItem id={0}
|
||||||
|
isOpen={openedAccordion}
|
||||||
|
onChange={handleAccordion}
|
||||||
|
label={t('app.admin.store.categories_filter.filter_categories')}>
|
||||||
|
<div className='content'>
|
||||||
|
<div className="group u-scrollbar">
|
||||||
|
{productCategories.map(pc => (
|
||||||
|
<label key={pc.id} className={pc.parent_id ? 'offset' : ''}>
|
||||||
|
<input type="checkbox" checked={selectedCategories.includes(pc)} onChange={(event) => handleSelectCategory(pc, event.target.checked)} />
|
||||||
|
<p>{pc.name}</p>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FabButton onClick={() => onApplyFilters(selectedCategories)} className="is-info">{t('app.admin.store.categories_filter.filter_apply')}</FabButton>
|
||||||
|
</div>
|
||||||
|
</AccordionItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,63 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { FabButton } from '../../base/fab-button';
|
||||||
|
import { AccordionItem } from '../../base/accordion-item';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
interface KeywordFilterProps {
|
||||||
|
onApplyFilters: (keywork: string) => void,
|
||||||
|
currentFilters?: string,
|
||||||
|
openDefault?: boolean,
|
||||||
|
instantUpdate?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to filter the products list by keyword or product reference
|
||||||
|
*/
|
||||||
|
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 || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!_.isEqual(currentFilters, keyword)) {
|
||||||
|
setKeyword(currentFilters);
|
||||||
|
}
|
||||||
|
}, [currentFilters]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open/close the accordion item
|
||||||
|
*/
|
||||||
|
const handleAccordion = (id, state: boolean) => {
|
||||||
|
setOpenedAccordion(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the user types anything in the input
|
||||||
|
*/
|
||||||
|
const handleKeywordTyping = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setKeyword(evt.target.value);
|
||||||
|
|
||||||
|
if (instantUpdate) {
|
||||||
|
onApplyFilters(evt.target.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AccordionItem id={2}
|
||||||
|
isOpen={openedAccordion}
|
||||||
|
onChange={handleAccordion}
|
||||||
|
label={t('app.admin.store.keyword_filter.filter_keywords_reference')}
|
||||||
|
>
|
||||||
|
<div className="content">
|
||||||
|
<div className="group">
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,81 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { FabButton } from '../../base/fab-button';
|
||||||
|
import { AccordionItem } from '../../base/accordion-item';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Machine } from '../../../models/machine';
|
||||||
|
import MachineAPI from '../../../api/machine';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
interface MachinesFilterProps {
|
||||||
|
onError: (message: string) => void,
|
||||||
|
onApplyFilters: (categories: Array<Machine>) => void,
|
||||||
|
currentFilters: Array<Machine>,
|
||||||
|
openDefault?: boolean,
|
||||||
|
instantUpdate?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to filter the products list by associated machine
|
||||||
|
*/
|
||||||
|
export const MachinesFilter: React.FC<MachinesFilterProps> = ({ onError, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
|
const [machines, setMachines] = useState<Machine[]>([]);
|
||||||
|
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
|
||||||
|
const [selectedMachines, setSelectedMachines] = useState<Machine[]>(currentFilters || []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
MachineAPI.index({ disabled: false }).then(data => {
|
||||||
|
setMachines(data);
|
||||||
|
}).catch(onError);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentFilters && !_.isEqual(currentFilters, selectedMachines)) {
|
||||||
|
setSelectedMachines(currentFilters);
|
||||||
|
}
|
||||||
|
}, [currentFilters]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open/close the accordion item
|
||||||
|
*/
|
||||||
|
const handleAccordion = (id, state: boolean) => {
|
||||||
|
setOpenedAccordion(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when a machine filter is seleced or unselected.
|
||||||
|
*/
|
||||||
|
const handleSelectMachine = (currentMachine: Machine, checked: boolean) => {
|
||||||
|
const list = [...selectedMachines];
|
||||||
|
checked
|
||||||
|
? list.push(currentMachine)
|
||||||
|
: list.splice(list.indexOf(currentMachine), 1);
|
||||||
|
|
||||||
|
setSelectedMachines(list);
|
||||||
|
if (instantUpdate) {
|
||||||
|
onApplyFilters(list);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AccordionItem id={1}
|
||||||
|
isOpen={openedAccordion}
|
||||||
|
onChange={handleAccordion}
|
||||||
|
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={selectedMachines.includes(m)} onChange={(event) => handleSelectMachine(m, event.target.checked)} />
|
||||||
|
<p>{m.name}</p>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FabButton onClick={() => onApplyFilters(selectedMachines)} className="is-info">{t('app.admin.store.machines_filter.filter_apply')}</FabButton>
|
||||||
|
</div>
|
||||||
|
</AccordionItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,92 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { FabButton } from '../../base/fab-button';
|
||||||
|
import { AccordionItem } from '../../base/accordion-item';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface StockFilterProps {
|
||||||
|
onApplyFilters: (filters: ProductIndexFilter) => void,
|
||||||
|
currentFilters: ProductIndexFilter,
|
||||||
|
openDefault?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option format, expected by react-select
|
||||||
|
* @see https://github.com/JedWatson/react-select
|
||||||
|
*/
|
||||||
|
type selectOption = { value: StockType, label: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to filter the products list by stock
|
||||||
|
*/
|
||||||
|
export const StockFilter: React.FC<StockFilterProps> = ({ onApplyFilters, currentFilters, openDefault = false }) => {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
|
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
|
||||||
|
|
||||||
|
const { register, control, handleSubmit, getValues, reset } = useForm<ProductIndexFilter>({ defaultValues: { ...currentFilters } });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentFilters && !_.isEqual(currentFilters, getValues())) {
|
||||||
|
reset(currentFilters);
|
||||||
|
}
|
||||||
|
}, [currentFilters]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open/close the accordion item
|
||||||
|
*/
|
||||||
|
const handleAccordion = (id, state: boolean) => {
|
||||||
|
setOpenedAccordion(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the user clicks on "apply" to apply teh current filters.
|
||||||
|
*/
|
||||||
|
const onSubmit = (data: ProductIndexFilter) => {
|
||||||
|
onApplyFilters(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Creates sorting options to the react-select format */
|
||||||
|
const buildStockOptions = (): Array<selectOption> => {
|
||||||
|
return [
|
||||||
|
{ value: 'internal', label: t('app.admin.store.stock_filter.stock_internal') },
|
||||||
|
{ value: 'external', label: t('app.admin.store.stock_filter.stock_external') }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AccordionItem id={3}
|
||||||
|
isOpen={openedAccordion}
|
||||||
|
onChange={handleAccordion}
|
||||||
|
label={t('app.admin.store.stock_filter.filter_stock')}>
|
||||||
|
<form className="content" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="group">
|
||||||
|
<FormSelect id="stock_type"
|
||||||
|
options={buildStockOptions()}
|
||||||
|
valueDefault="internal"
|
||||||
|
control={control}
|
||||||
|
/>
|
||||||
|
<div className='range'>
|
||||||
|
<FormInput id="stock_from"
|
||||||
|
label={t('app.admin.store.stock_filter.filter_stock_from')}
|
||||||
|
register={register}
|
||||||
|
defaultValue={0}
|
||||||
|
type="number" />
|
||||||
|
<FormInput id="stock_to"
|
||||||
|
label={t('app.admin.store.stock_filter.filter_stock_to')}
|
||||||
|
register={register}
|
||||||
|
defaultValue={0}
|
||||||
|
type="number" />
|
||||||
|
</div>
|
||||||
|
<FabButton type="submit" className="is-info">{t('app.admin.store.stock_filter.filter_apply')}</FabButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AccordionItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -7,13 +7,13 @@ import { IApplication } from '../../models/application';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { FabButton } from '../base/fab-button';
|
import { FabButton } from '../base/fab-button';
|
||||||
import { StoreListHeader } from './store-list-header';
|
import { StoreListHeader } from './store-list-header';
|
||||||
import { AccordionItem } from './accordion-item';
|
import { AccordionItem } from '../base/accordion-item';
|
||||||
import { OrderItem } from './order-item';
|
import { OrderItem } from './order-item';
|
||||||
import { MemberSelect } from '../user/member-select';
|
import { MemberSelect } from '../user/member-select';
|
||||||
import { User } from '../../models/user';
|
import { User } from '../../models/user';
|
||||||
import { FormInput } from '../form/form-input';
|
import { FormInput } from '../form/form-input';
|
||||||
import OrderAPI from '../../api/order';
|
import OrderAPI from '../../api/order';
|
||||||
import { Order, OrderIndexFilter } from '../../models/order';
|
import { Order, OrderIndexFilter, OrderSortOption } from '../../models/order';
|
||||||
import { FabPagination } from '../base/fab-pagination';
|
import { FabPagination } from '../base/fab-pagination';
|
||||||
import { CaretDoubleUp, X } from 'phosphor-react';
|
import { CaretDoubleUp, X } from 'phosphor-react';
|
||||||
|
|
||||||
@ -27,7 +27,7 @@ interface OrdersProps {
|
|||||||
* Option format, expected by react-select
|
* Option format, expected by react-select
|
||||||
* @see https://github.com/JedWatson/react-select
|
* @see https://github.com/JedWatson/react-select
|
||||||
*/
|
*/
|
||||||
type selectOption = { value: number, label: string };
|
type selectOption = { value: OrderSortOption, label: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Option format, expected by checklist
|
* Option format, expected by checklist
|
||||||
@ -38,7 +38,7 @@ const initFilters: OrderIndexFilter = {
|
|||||||
reference: '',
|
reference: '',
|
||||||
states: [],
|
states: [],
|
||||||
page: 1,
|
page: 1,
|
||||||
sort: 'DESC'
|
sort: 'created_at-desc'
|
||||||
};
|
};
|
||||||
|
|
||||||
const FablabOrdersFilters = 'FablabOrdersFilters';
|
const FablabOrdersFilters = 'FablabOrdersFilters';
|
||||||
@ -127,7 +127,7 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onError }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
setFilters(draft => {
|
setFilters(draft => {
|
||||||
draft.page = 1;
|
draft.page = 1;
|
||||||
draft.sort = 'DESC';
|
draft.sort = 'created_at-desc';
|
||||||
switch (filterType) {
|
switch (filterType) {
|
||||||
case 'reference':
|
case 'reference':
|
||||||
draft.reference = '';
|
draft.reference = '';
|
||||||
@ -176,8 +176,8 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onError }) => {
|
|||||||
*/
|
*/
|
||||||
const buildOptions = (): Array<selectOption> => {
|
const buildOptions = (): Array<selectOption> => {
|
||||||
return [
|
return [
|
||||||
{ value: 0, label: t('app.admin.store.orders.sort.newest') },
|
{ value: 'created_at-desc', label: t('app.admin.store.orders.sort.newest') },
|
||||||
{ value: 1, label: t('app.admin.store.orders.sort.oldest') }
|
{ value: 'created_at-asc', label: t('app.admin.store.orders.sort.oldest') }
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -186,7 +186,7 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onError }) => {
|
|||||||
*/
|
*/
|
||||||
const handleSorting = (option: selectOption) => {
|
const handleSorting = (option: selectOption) => {
|
||||||
setFilters(draft => {
|
setFilters(draft => {
|
||||||
draft.sort = option.value ? 'ASC' : 'DESC';
|
draft.sort = option.value;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -339,7 +339,7 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onError }) => {
|
|||||||
<StoreListHeader
|
<StoreListHeader
|
||||||
productsCount={totalCount}
|
productsCount={totalCount}
|
||||||
selectOptions={buildOptions()}
|
selectOptions={buildOptions()}
|
||||||
selectValue={filters.sort === 'ASC' ? 1 : 0}
|
selectValue={filters.sort}
|
||||||
onSelectOptionsChange={handleSorting}
|
onSelectOptionsChange={handleSorting}
|
||||||
/>
|
/>
|
||||||
<div className='features'>
|
<div className='features'>
|
||||||
|
@ -4,21 +4,22 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { react2angular } from 'react2angular';
|
import { react2angular } from 'react2angular';
|
||||||
import { Loader } from '../base/loader';
|
import { Loader } from '../base/loader';
|
||||||
import { IApplication } from '../../models/application';
|
import { IApplication } from '../../models/application';
|
||||||
import { Product } from '../../models/product';
|
import { Product, ProductIndexFilter, ProductsIndex, ProductSortOption } from '../../models/product';
|
||||||
import { ProductCategory } from '../../models/product-category';
|
import { ProductCategory } from '../../models/product-category';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { FabButton } from '../base/fab-button';
|
import { FabButton } from '../base/fab-button';
|
||||||
import { ProductItem } from './product-item';
|
import { ProductItem } from './product-item';
|
||||||
import ProductAPI from '../../api/product';
|
import ProductAPI from '../../api/product';
|
||||||
import ProductCategoryAPI from '../../api/product-category';
|
|
||||||
import MachineAPI from '../../api/machine';
|
|
||||||
import { AccordionItem } from './accordion-item';
|
|
||||||
import { CaretDoubleUp, X } from 'phosphor-react';
|
|
||||||
import { StoreListHeader } from './store-list-header';
|
import { StoreListHeader } from './store-list-header';
|
||||||
import { FabPagination } from '../base/fab-pagination';
|
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 } from './filters/stock-filter';
|
||||||
|
import ProductCategoryAPI from '../../api/product-category';
|
||||||
import ProductLib from '../../lib/product';
|
import ProductLib from '../../lib/product';
|
||||||
import { FormInput } from '../form/form-input';
|
import { ActiveFiltersTags } from './filters/active-filters-tags';
|
||||||
import { FormSelect } from '../form/form-select';
|
import { CaretDoubleUp } from 'phosphor-react';
|
||||||
|
|
||||||
declare const Application: IApplication;
|
declare const Application: IApplication;
|
||||||
|
|
||||||
@ -30,62 +31,63 @@ interface ProductsProps {
|
|||||||
* Option format, expected by react-select
|
* Option format, expected by react-select
|
||||||
* @see https://github.com/JedWatson/react-select
|
* @see https://github.com/JedWatson/react-select
|
||||||
*/
|
*/
|
||||||
type selectOption = { value: number, label: string };
|
type selectOption = { value: ProductSortOption, label: string };
|
||||||
|
|
||||||
/** This component shows the admin view of the store */
|
/** This component shows the admin view of the store */
|
||||||
const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
const { register, control, getValues } = useForm();
|
const [productCategories, setProductCategories] = useState<Array<ProductCategory>>([]);
|
||||||
|
const [productsList, setProductList] = useState<Array<Product>>([]);
|
||||||
const [filteredProductsList, setFilteredProductList] = useImmer<Array<Product>>([]);
|
const [filters, setFilters] = useImmer<ProductIndexFilter>(initFilters);
|
||||||
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 [productCategories, setProductCategories] = useState<ProductCategory[]>([]);
|
|
||||||
const [machines, setMachines] = useState<checklistOption[]>([]);
|
|
||||||
const [update, setUpdate] = useState(false);
|
|
||||||
const [accordion, setAccordion] = useState({});
|
|
||||||
const [filtersPanel, setFiltersPanel] = useState<boolean>(true);
|
|
||||||
const [pageCount, setPageCount] = useState<number>(0);
|
const [pageCount, setPageCount] = useState<number>(0);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
|
const [productsCount, setProductsCount] = useState<number>(0);
|
||||||
|
const [filtersPanel, setFiltersPanel] = useState<boolean>(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ProductAPI.index({ page: 1, is_active: filterVisible }).then(data => {
|
fetchProducts().then(scrollToProducts);
|
||||||
setPageCount(data.total_pages);
|
|
||||||
setFilteredProductList(data.products);
|
|
||||||
});
|
|
||||||
|
|
||||||
ProductCategoryAPI.index().then(data => {
|
ProductCategoryAPI.index().then(data => {
|
||||||
setProductCategories(ProductLib.sortCategories(data));
|
setProductCategories(ProductLib.sortCategories(data));
|
||||||
}).catch(onError);
|
}).catch(onError);
|
||||||
|
|
||||||
MachineAPI.index({ disabled: false }).then(data => {
|
|
||||||
setMachines(buildChecklistOptions(data));
|
|
||||||
}).catch(onError);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyFilters();
|
fetchProducts().then(scrollToProducts);
|
||||||
setClearFilters(false);
|
}, [filters]);
|
||||||
setUpdate(false);
|
|
||||||
}, [filterVisible, clearFilters, update === true]);
|
|
||||||
|
|
||||||
/** Handle products pagination */
|
/** Handle products pagination */
|
||||||
const handlePagination = (page: number) => {
|
const handlePagination = (page: number) => {
|
||||||
if (page !== currentPage) {
|
if (page !== currentPage) {
|
||||||
ProductAPI.index({ page, is_active: filterVisible }).then(data => {
|
setFilters(draft => {
|
||||||
setCurrentPage(page);
|
return { ...draft, 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'));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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') + error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll the view to the product list
|
||||||
|
*/
|
||||||
|
const scrollToProducts = () => {
|
||||||
|
window.document.getElementById('content-main').scrollTo({ top: 100, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
/** Goto edit product page */
|
/** Goto edit product page */
|
||||||
const editProduct = (product: Product) => {
|
const editProduct = (product: Product) => {
|
||||||
window.location.href = `/#!/admin/store/products/${product.id}/edit`;
|
window.location.href = `/#!/admin/store/products/${product.id}/edit`;
|
||||||
@ -95,8 +97,8 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
|||||||
const deleteProduct = async (productId: number): Promise<void> => {
|
const deleteProduct = async (productId: number): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await ProductAPI.destroy(productId);
|
await ProductAPI.destroy(productId);
|
||||||
const data = await ProductAPI.index();
|
await fetchProducts();
|
||||||
setFilteredProductList(data.products);
|
scrollToProducts();
|
||||||
onSuccess(t('app.admin.store.products.successfully_deleted'));
|
onSuccess(t('app.admin.store.products.successfully_deleted'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError(t('app.admin.store.products.unable_to_delete') + e);
|
onError(t('app.admin.store.products.unable_to_delete') + e);
|
||||||
@ -110,131 +112,81 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
|||||||
|
|
||||||
/** Filter: toggle non-available products visibility */
|
/** Filter: toggle non-available products visibility */
|
||||||
const toggleVisible = (checked: boolean) => {
|
const toggleVisible = (checked: boolean) => {
|
||||||
setFilterVisible(!filterVisible);
|
setFilters(draft => {
|
||||||
console.log('Display on the shelf product only:', checked);
|
return { ...draft, is_active: checked };
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Filter: by categories */
|
/**
|
||||||
const handleSelectCategory = (c: ProductCategory, checked: boolean, instantUpdate?: boolean) => {
|
* Update the list of applied filters with the given categories
|
||||||
let list = [...filters.categories];
|
*/
|
||||||
const children = productCategories
|
const handleCategoriesFilterUpdate = (categories: Array<ProductCategory>) => {
|
||||||
.filter(el => el.parent_id === c.id);
|
|
||||||
const siblings = productCategories
|
|
||||||
.filter(el => el.parent_id === c.parent_id && el.parent_id !== null);
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
list.push(c);
|
|
||||||
if (children.length) {
|
|
||||||
const unique = Array.from(new Set([...list, ...children]));
|
|
||||||
list = [...unique];
|
|
||||||
}
|
|
||||||
if (siblings.length && siblings.every(el => list.includes(el))) {
|
|
||||||
list.push(productCategories.find(p => p.id === siblings[0].parent_id));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
list.splice(list.indexOf(c), 1);
|
|
||||||
const parent = productCategories.find(p => p.id === c.parent_id);
|
|
||||||
if (c.parent_id && list.includes(parent)) {
|
|
||||||
list.splice(list.indexOf(parent), 1);
|
|
||||||
}
|
|
||||||
if (children.length) {
|
|
||||||
children.forEach(child => {
|
|
||||||
list.splice(list.indexOf(child), 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setFilters(draft => {
|
setFilters(draft => {
|
||||||
return { ...draft, categories: list };
|
return { ...draft, categories };
|
||||||
});
|
});
|
||||||
if (instantUpdate) {
|
|
||||||
setUpdate(true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Filter: by machines */
|
/**
|
||||||
const handleSelectMachine = (m: checklistOption, checked, instantUpdate?) => {
|
* Remove the provided category from the filters selection
|
||||||
const list = [...filters.machines];
|
*/
|
||||||
checked
|
const handleRemoveCategory = (category: ProductCategory) => {
|
||||||
? list.push(m)
|
const list = ProductLib.categoriesSelectionTree(productCategories, filters.categories, category, 'remove');
|
||||||
: list.splice(list.indexOf(m), 1);
|
handleCategoriesFilterUpdate(list);
|
||||||
setFilters(draft => {
|
|
||||||
return { ...draft, machines: list };
|
|
||||||
});
|
|
||||||
if (instantUpdate) {
|
|
||||||
setUpdate(true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Filter: by keyword or ref */
|
/**
|
||||||
const handleKeyword = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
* Update the list of applied filters with the given machines
|
||||||
|
*/
|
||||||
|
const handleMachinesFilterUpdate = (machines: Array<Machine>) => {
|
||||||
setFilters(draft => {
|
setFilters(draft => {
|
||||||
return { ...draft, keywords: evt.target.value };
|
return { ...draft, machines };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the list of applied filters with the given keywords (or reference)
|
||||||
|
*/
|
||||||
|
const handleKeywordFilterUpdate = (keywords: Array<string>) => {
|
||||||
|
setFilters(draft => {
|
||||||
|
return { ...draft, keywords };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Filter: by stock range */
|
/** Filter: by stock range */
|
||||||
const handleStockRange = () => {
|
const handleStockFilterUpdate = (filters: ProductIndexFilter) => {
|
||||||
setFilters(draft => {
|
setFilters(draft => {
|
||||||
return {
|
return {
|
||||||
...draft,
|
...draft,
|
||||||
stock_type: buildStockOptions()[getValues('stock_type')].label,
|
...filters
|
||||||
stock_from: getValues('stock_from'),
|
|
||||||
stock_to: getValues('stock_to')
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Creates sorting options to the react-select format */
|
|
||||||
const buildStockOptions = (): Array<selectOption> => {
|
|
||||||
return [
|
|
||||||
{ value: 0, label: t('app.admin.store.products.stock_internal') },
|
|
||||||
{ value: 1, label: t('app.admin.store.products.stock_external') }
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Display option: sorting */
|
/** Display option: sorting */
|
||||||
const handleSorting = (option: selectOption) => {
|
const handleSorting = (option: selectOption) => {
|
||||||
console.log('Sort option:', option);
|
setFilters(draft => {
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
sort: option.value
|
||||||
};
|
};
|
||||||
|
});
|
||||||
/** 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 */
|
/** Clear filters */
|
||||||
const clearAllFilters = () => {
|
const clearAllFilters = () => {
|
||||||
setFilters(initFilters);
|
setFilters(initFilters);
|
||||||
setClearFilters(true);
|
|
||||||
console.log('Clear all filters');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Creates sorting options to the react-select format */
|
/** Creates sorting options to the react-select format */
|
||||||
const buildSortOptions = (): Array<selectOption> => {
|
const buildSortOptions = (): Array<selectOption> => {
|
||||||
return [
|
return [
|
||||||
{ value: 0, label: t('app.admin.store.products.sort.name_az') },
|
{ value: 'name-asc', label: t('app.admin.store.products.sort.name_az') },
|
||||||
{ value: 1, label: t('app.admin.store.products.sort.name_za') },
|
{ value: 'name-desc', label: t('app.admin.store.products.sort.name_za') },
|
||||||
{ value: 2, label: t('app.admin.store.products.sort.price_low') },
|
{ value: 'amount-asc', label: t('app.admin.store.products.sort.price_low') },
|
||||||
{ value: 3, label: t('app.admin.store.products.sort.price_high') }
|
{ value: 'amount-desc', label: t('app.admin.store.products.sort.price_high') }
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Open/close accordion items */
|
|
||||||
const handleAccordion = (id, state) => {
|
|
||||||
setAccordion({ ...accordion, [id]: state });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='products'>
|
<div className='products'>
|
||||||
<header>
|
<header>
|
||||||
@ -252,110 +204,39 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className='grp accordion'>
|
<div className='grp accordion'>
|
||||||
<AccordionItem id={0}
|
<CategoriesFilter productCategories={productCategories}
|
||||||
isOpen={accordion[0]}
|
onApplyFilters={handleCategoriesFilterUpdate}
|
||||||
onChange={handleAccordion}
|
currentFilters={filters.categories} />
|
||||||
label={t('app.admin.store.products.filter_categories')}
|
|
||||||
>
|
|
||||||
<div className='content'>
|
|
||||||
<div className="group u-scrollbar">
|
|
||||||
{productCategories.map(pc => (
|
|
||||||
<label key={pc.id} className={pc.parent_id ? 'offset' : ''}>
|
|
||||||
<input type="checkbox" checked={filters.categories.includes(pc)} onChange={(event) => handleSelectCategory(pc, event.target.checked)} />
|
|
||||||
<p>{pc.name}</p>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<FabButton onClick={() => setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
|
|
||||||
</div>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem id={1}
|
<MachinesFilter onError={onError}
|
||||||
isOpen={accordion[1]}
|
onApplyFilters={handleMachinesFilterUpdate}
|
||||||
onChange={handleAccordion}
|
currentFilters={filters.machines} />
|
||||||
label={t('app.admin.store.products.filter_machines')}
|
|
||||||
>
|
|
||||||
<div className='content'>
|
|
||||||
<div className="group u-scrollbar">
|
|
||||||
{machines.map(m => (
|
|
||||||
<label key={m.value}>
|
|
||||||
<input type="checkbox" checked={filters.machines.includes(m)} onChange={(event) => handleSelectMachine(m, event.target.checked)} />
|
|
||||||
<p>{m.label}</p>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<FabButton onClick={() => setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
|
|
||||||
</div>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem id={2}
|
<KeywordFilter onApplyFilters={keyword => handleKeywordFilterUpdate([keyword])}
|
||||||
isOpen={accordion[2]}
|
currentFilters={filters.keywords[0]} />
|
||||||
onChange={handleAccordion}
|
|
||||||
label={t('app.admin.store.products.filter_keywords_reference')}
|
|
||||||
>
|
|
||||||
<div className="content">
|
|
||||||
<div className="group">
|
|
||||||
<input type="text" onChange={event => handleKeyword(event)} />
|
|
||||||
<FabButton onClick={() => setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem id={3}
|
<StockFilter onApplyFilters={handleStockFilterUpdate}
|
||||||
isOpen={accordion[3]}
|
currentFilters={filters} />
|
||||||
onChange={handleAccordion}
|
|
||||||
label={t('app.admin.store.products.filter_stock')}
|
|
||||||
>
|
|
||||||
<div className="content">
|
|
||||||
<div className="group">
|
|
||||||
<FormSelect id="stock_type"
|
|
||||||
options={buildStockOptions()}
|
|
||||||
valueDefault={0}
|
|
||||||
control={control}
|
|
||||||
/>
|
|
||||||
<div className='range'>
|
|
||||||
<FormInput id="stock_from"
|
|
||||||
label={t('app.admin.store.products.filter_stock_from')}
|
|
||||||
register={register}
|
|
||||||
defaultValue={filters.stock_from}
|
|
||||||
type="number" />
|
|
||||||
<FormInput id="stock_to"
|
|
||||||
label={t('app.admin.store.products.filter_stock_to')}
|
|
||||||
register={register}
|
|
||||||
defaultValue={filters.stock_to}
|
|
||||||
type="number" />
|
|
||||||
</div>
|
|
||||||
<FabButton onClick={handleStockRange} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionItem>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div className='store-list'>
|
<div className='store-list'>
|
||||||
<StoreListHeader
|
<StoreListHeader
|
||||||
productsCount={filteredProductsList.length}
|
productsCount={productsCount}
|
||||||
selectOptions={buildSortOptions()}
|
selectOptions={buildSortOptions()}
|
||||||
onSelectOptionsChange={handleSorting}
|
onSelectOptionsChange={handleSorting}
|
||||||
switchChecked={filterVisible}
|
switchChecked={filters.is_active}
|
||||||
onSwitch={toggleVisible}
|
onSwitch={toggleVisible}
|
||||||
/>
|
/>
|
||||||
<div className='features'>
|
<div className='features'>
|
||||||
{features.categories.map(c => (
|
<ActiveFiltersTags filters={filters}
|
||||||
<div key={c.id} className='features-item'>
|
onRemoveCategory={handleRemoveCategory}
|
||||||
<p>{c.name}</p>
|
onRemoveMachine={(m) => handleMachinesFilterUpdate(filters.machines.filter(machine => machine !== m))}
|
||||||
<button onClick={() => handleSelectCategory(c, false, true)}><X size={16} weight="light" /></button>
|
onRemoveKeyword={() => handleKeywordFilterUpdate([])}
|
||||||
</div>
|
onRemoveStock={() => handleStockFilterUpdate({ stock_type: 'internal', stock_to: 0, stock_from: 0 })} />
|
||||||
))}
|
|
||||||
{features.machines.map(m => (
|
|
||||||
<div key={m.value} className='features-item'>
|
|
||||||
<p>{m.label}</p>
|
|
||||||
<button onClick={() => handleSelectMachine(m, false, true)}><X size={16} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="products-list">
|
<div className="products-list">
|
||||||
{filteredProductsList.map((product) => (
|
{productsList.map((product) => (
|
||||||
<ProductItem
|
<ProductItem
|
||||||
key={product.id}
|
key={product.id}
|
||||||
product={product}
|
product={product}
|
||||||
@ -382,30 +263,14 @@ const ProductsWrapper: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
|||||||
|
|
||||||
Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError']));
|
Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError']));
|
||||||
|
|
||||||
/** Option format, expected by checklist */
|
const initFilters: ProductIndexFilter = {
|
||||||
type checklistOption = { value: number, label: string };
|
|
||||||
|
|
||||||
/** Convert the provided array of items to the checklist format */
|
|
||||||
const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array<checklistOption> => {
|
|
||||||
return items.map(t => {
|
|
||||||
return { value: t.id, label: t.name };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Filters {
|
|
||||||
categories: ProductCategory[],
|
|
||||||
machines: checklistOption[],
|
|
||||||
keywords: string[],
|
|
||||||
stock_type: 'internal' | 'external',
|
|
||||||
stock_from: number,
|
|
||||||
stock_to: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const initFilters: Filters = {
|
|
||||||
categories: [],
|
categories: [],
|
||||||
machines: [],
|
machines: [],
|
||||||
keywords: [],
|
keywords: [],
|
||||||
stock_type: 'internal',
|
stock_type: 'internal',
|
||||||
stock_from: 0,
|
stock_from: 0,
|
||||||
stock_to: 0
|
stock_to: 0,
|
||||||
|
is_active: false,
|
||||||
|
page: 1,
|
||||||
|
sort: ''
|
||||||
};
|
};
|
||||||
|
@ -2,12 +2,13 @@ import React from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
import Switch from 'react-switch';
|
import Switch from 'react-switch';
|
||||||
|
import { SortOption } from '../../models/api';
|
||||||
|
|
||||||
interface StoreListHeaderProps {
|
interface StoreListHeaderProps {
|
||||||
productsCount: number,
|
productsCount: number,
|
||||||
selectOptions: selectOption[],
|
selectOptions: selectOption[],
|
||||||
onSelectOptionsChange: (option: selectOption) => void,
|
onSelectOptionsChange: (option: selectOption) => void,
|
||||||
selectValue?: number,
|
selectValue?: SortOption,
|
||||||
switchLabel?: string,
|
switchLabel?: string,
|
||||||
switchChecked?: boolean,
|
switchChecked?: boolean,
|
||||||
onSwitch?: (boolean) => void
|
onSwitch?: (boolean) => void
|
||||||
@ -16,7 +17,7 @@ interface StoreListHeaderProps {
|
|||||||
* Option format, expected by react-select
|
* Option format, expected by react-select
|
||||||
* @see https://github.com/JedWatson/react-select
|
* @see https://github.com/JedWatson/react-select
|
||||||
*/
|
*/
|
||||||
type selectOption = { value: number, label: string };
|
type selectOption = { value: SortOption, label: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders an accordion item
|
* Renders an accordion item
|
||||||
|
@ -4,7 +4,7 @@ import { react2angular } from 'react2angular';
|
|||||||
import { Loader } from '../base/loader';
|
import { Loader } from '../base/loader';
|
||||||
import { IApplication } from '../../models/application';
|
import { IApplication } from '../../models/application';
|
||||||
import { FabButton } from '../base/fab-button';
|
import { FabButton } from '../base/fab-button';
|
||||||
import { Product } from '../../models/product';
|
import { Product, ProductSortOption } from '../../models/product';
|
||||||
import { ProductCategory } from '../../models/product-category';
|
import { ProductCategory } from '../../models/product-category';
|
||||||
import ProductAPI from '../../api/product';
|
import ProductAPI from '../../api/product';
|
||||||
import ProductCategoryAPI from '../../api/product-category';
|
import ProductCategoryAPI from '../../api/product-category';
|
||||||
@ -13,7 +13,7 @@ import { StoreProductItem } from './store-product-item';
|
|||||||
import useCart from '../../hooks/use-cart';
|
import useCart from '../../hooks/use-cart';
|
||||||
import { User } from '../../models/user';
|
import { User } from '../../models/user';
|
||||||
import { Order } from '../../models/order';
|
import { Order } from '../../models/order';
|
||||||
import { AccordionItem } from './accordion-item';
|
import { AccordionItem } from '../base/accordion-item';
|
||||||
import { StoreListHeader } from './store-list-header';
|
import { StoreListHeader } from './store-list-header';
|
||||||
import { FabPagination } from '../base/fab-pagination';
|
import { FabPagination } from '../base/fab-pagination';
|
||||||
import { CaretDoubleDown } from 'phosphor-react';
|
import { CaretDoubleDown } from 'phosphor-react';
|
||||||
@ -29,7 +29,7 @@ interface StoreProps {
|
|||||||
* Option format, expected by react-select
|
* Option format, expected by react-select
|
||||||
* @see https://github.com/JedWatson/react-select
|
* @see https://github.com/JedWatson/react-select
|
||||||
*/
|
*/
|
||||||
type selectOption = { value: number, label: string };
|
type selectOption = { value: ProductSortOption, label: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component shows public store
|
* This component shows public store
|
||||||
@ -53,21 +53,21 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ProductAPI.index({ page: 1, is_active: filterVisible }).then(data => {
|
ProductAPI.index({ page: 1, is_active: filterVisible }).then(data => {
|
||||||
setPageCount(data.total_pages);
|
setPageCount(data.total_pages);
|
||||||
setProducts(data.products);
|
setProducts(data.data);
|
||||||
}).catch(() => {
|
}).catch(error => {
|
||||||
onError(t('app.public.store.unexpected_error_occurred'));
|
onError(t('app.public.store.unexpected_error_occurred') + error);
|
||||||
});
|
});
|
||||||
ProductCategoryAPI.index().then(data => {
|
ProductCategoryAPI.index().then(data => {
|
||||||
setProductCategories(data);
|
setProductCategories(data);
|
||||||
formatCategories(data);
|
formatCategories(data);
|
||||||
}).catch(() => {
|
}).catch(error => {
|
||||||
onError(t('app.public.store.unexpected_error_occurred'));
|
onError(t('app.public.store.unexpected_error_occurred') + error);
|
||||||
});
|
});
|
||||||
|
|
||||||
MachineAPI.index({ disabled: false }).then(data => {
|
MachineAPI.index({ disabled: false }).then(data => {
|
||||||
setMachines(buildChecklistOptions(data));
|
setMachines(buildChecklistOptions(data));
|
||||||
}).catch(() => {
|
}).catch(error => {
|
||||||
onError(t('app.public.store.unexpected_error_occurred'));
|
onError(t('app.public.store.unexpected_error_occurred') + error);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -119,10 +119,10 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser }) => {
|
|||||||
*/
|
*/
|
||||||
const buildOptions = (): Array<selectOption> => {
|
const buildOptions = (): Array<selectOption> => {
|
||||||
return [
|
return [
|
||||||
{ value: 0, label: t('app.public.store.products.sort.name_az') },
|
{ value: 'name-asc', label: t('app.public.store.products.sort.name_az') },
|
||||||
{ value: 1, label: t('app.public.store.products.sort.name_za') },
|
{ value: 'name-desc', label: t('app.public.store.products.sort.name_za') },
|
||||||
{ value: 2, label: t('app.public.store.products.sort.price_low') },
|
{ value: 'amount-asc', label: t('app.public.store.products.sort.price_low') },
|
||||||
{ value: 3, label: t('app.public.store.products.sort.price_high') }
|
{ value: 'amount-desc', label: t('app.public.store.products.sort.price_high') }
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
@ -155,7 +155,7 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser }) => {
|
|||||||
if (page !== currentPage) {
|
if (page !== currentPage) {
|
||||||
ProductAPI.index({ page, is_active: filterVisible }).then(data => {
|
ProductAPI.index({ page, is_active: filterVisible }).then(data => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
setProducts(data.products);
|
setProducts(data.data);
|
||||||
setPageCount(data.total_pages);
|
setPageCount(data.total_pages);
|
||||||
window.document.getElementById('content-main').scrollTo({ top: 100, behavior: 'smooth' });
|
window.document.getElementById('content-main').scrollTo({ top: 100, behavior: 'smooth' });
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
import { ApiFilter } from '../models/api';
|
import { ApiFilter } from '../models/api';
|
||||||
|
|
||||||
export default class ApiLib {
|
export default class ApiLib {
|
||||||
static filtersToQuery (filters?: ApiFilter): string {
|
static filtersToQuery (filters?: ApiFilter): string {
|
||||||
if (!filters) return '';
|
if (!filters) return '';
|
||||||
|
|
||||||
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
|
return '?' + Object.entries(filters)
|
||||||
|
.filter(filter => !_.isNil(filter[1]))
|
||||||
|
.map(filter => `${filter[0]}=${filter[1]}`)
|
||||||
|
.join('&');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import { ProductCategory } from '../models/product-category';
|
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 {
|
export default class ProductLib {
|
||||||
/**
|
/**
|
||||||
@ -48,4 +53,53 @@ export default class ProductLib {
|
|||||||
return `-${quantity}`;
|
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)
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,12 @@
|
|||||||
// ApiFilter should be extended by an interface listing all the filters allowed for a given API
|
// 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 = Record<string, unknown>;
|
||||||
export type ApiFilter = {};
|
|
||||||
|
export interface PaginatedIndex<T> {
|
||||||
|
page: number,
|
||||||
|
total_pages: number,
|
||||||
|
page_size: number,
|
||||||
|
total_count: number,
|
||||||
|
data: Array<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SortOption = `${string}-${'asc' | 'desc'}` | '';
|
||||||
|
@ -3,6 +3,7 @@ import { PaymentConfirmation } from './payment';
|
|||||||
import { CreateTokenResponse } from './payzen';
|
import { CreateTokenResponse } from './payzen';
|
||||||
import { UserRole } from './user';
|
import { UserRole } from './user';
|
||||||
import { Coupon } from './coupon';
|
import { Coupon } from './coupon';
|
||||||
|
import { ApiFilter, PaginatedIndex } from './api';
|
||||||
|
|
||||||
export interface Order {
|
export interface Order {
|
||||||
id: number,
|
id: number,
|
||||||
@ -45,15 +46,11 @@ export interface OrderPayment {
|
|||||||
payment?: PaymentConfirmation|CreateTokenResponse
|
payment?: PaymentConfirmation|CreateTokenResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderIndex {
|
export type OrderIndex = PaginatedIndex<Order>;
|
||||||
page: number,
|
|
||||||
total_pages: number,
|
|
||||||
page_size: number,
|
|
||||||
total_count: number,
|
|
||||||
data: Array<Order>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrderIndexFilter {
|
export type OrderSortOption = 'created_at-asc' | 'created_at-desc' | '';
|
||||||
|
|
||||||
|
export interface OrderIndexFilter extends ApiFilter {
|
||||||
reference?: string,
|
reference?: string,
|
||||||
user_id?: number,
|
user_id?: number,
|
||||||
user?: {
|
user?: {
|
||||||
@ -61,7 +58,7 @@ export interface OrderIndexFilter {
|
|||||||
name?: string,
|
name?: string,
|
||||||
},
|
},
|
||||||
page?: number,
|
page?: number,
|
||||||
sort?: 'DESC'|'ASC'
|
sort?: OrderSortOption
|
||||||
states?: Array<string>,
|
states?: Array<string>,
|
||||||
period_from?: string,
|
period_from?: string,
|
||||||
period_to?: string
|
period_to?: string
|
||||||
|
@ -1,9 +1,25 @@
|
|||||||
import { TDateISO } from '../typings/date-iso';
|
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 type ProductSortOption = 'name-asc' | 'name-desc' | 'amount-asc' | 'amount-desc' | '';
|
||||||
|
|
||||||
|
export interface ProductIndexFilter {
|
||||||
is_active?: boolean,
|
is_active?: boolean,
|
||||||
page?: number
|
page?: number,
|
||||||
|
categories?: ProductCategory[],
|
||||||
|
machines?: Machine[],
|
||||||
|
keywords?: string[],
|
||||||
|
stock_type?: 'internal' | 'external',
|
||||||
|
stock_from?: number,
|
||||||
|
stock_to?: number,
|
||||||
|
sort?: ProductSortOption
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductIndexFilterIds extends Omit<Omit<ProductIndexFilter, 'categories'>, 'machines'>, ApiFilter {
|
||||||
|
categories?: Array<number>,
|
||||||
|
machines?: Array<number>,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StockType = 'internal' | 'external' | 'all';
|
export type StockType = 'internal' | 'external' | 'all';
|
||||||
@ -19,10 +35,7 @@ export interface Stock {
|
|||||||
external: number,
|
external: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductsIndex {
|
export type ProductsIndex = PaginatedIndex<Product>;
|
||||||
total_pages?: number,
|
|
||||||
products: Array<Product>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductStockMovement {
|
export interface ProductStockMovement {
|
||||||
id?: number,
|
id?: number,
|
||||||
|
@ -10,12 +10,14 @@ class Machine < ApplicationRecord
|
|||||||
has_many :machine_files, as: :viewable, dependent: :destroy
|
has_many :machine_files, as: :viewable, dependent: :destroy
|
||||||
accepts_nested_attributes_for :machine_files, allow_destroy: true, reject_if: :all_blank
|
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 :machines_availabilities, dependent: :destroy
|
||||||
has_many :availabilities, through: :machines_availabilities
|
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 :name, presence: true, length: { maximum: 50 }
|
||||||
validates :description, presence: true
|
validates :description, presence: true
|
||||||
@ -27,9 +29,10 @@ class Machine < ApplicationRecord
|
|||||||
has_many :credits, as: :creditable, dependent: :destroy
|
has_many :credits, as: :creditable, dependent: :destroy
|
||||||
has_many :plans, through: :credits
|
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_statistic_subtype
|
||||||
after_create :create_machine_prices
|
after_create :create_machine_prices
|
||||||
@ -66,11 +69,11 @@ class Machine < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_machine_prices
|
def create_machine_prices
|
||||||
Group.all.each do |group|
|
Group.find_each do |group|
|
||||||
Price.create(priceable: self, group: group, amount: 0)
|
Price.create(priceable: self, group: group, amount: 0)
|
||||||
end
|
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)
|
Price.create(group: plan.group, plan: plan, priceable: self, amount: 0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
7
app/models/machines_product.rb
Normal file
7
app/models/machines_product.rb
Normal 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
|
@ -8,7 +8,8 @@ class Product < ApplicationRecord
|
|||||||
|
|
||||||
belongs_to :product_category
|
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
|
has_many :product_files, as: :viewable, dependent: :destroy
|
||||||
accepts_nested_attributes_for :product_files, allow_destroy: true, reject_if: :all_blank
|
accepts_nested_attributes_for :product_files, allow_destroy: true, reject_if: :all_blank
|
||||||
|
7
app/models/projects_machine.rb
Normal file
7
app/models/projects_machine.rb
Normal 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
|
7
app/models/trainings_machine.rb
Normal file
7
app/models/trainings_machine.rb
Normal 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
|
@ -1,19 +1,19 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
# Provides methods for pay cart
|
# Provides methods to pay the cart
|
||||||
class Checkout::PaymentService
|
class Checkout::PaymentService
|
||||||
require 'pay_zen/helper'
|
require 'pay_zen/helper'
|
||||||
require 'stripe/helper'
|
require 'stripe/helper'
|
||||||
include Payments::PaymentConcern
|
include Payments::PaymentConcern
|
||||||
|
|
||||||
def payment(order, operator, coupon_code, payment_id = '')
|
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)
|
CouponService.new.validate(coupon_code, order.statistic_profile.user.id)
|
||||||
|
|
||||||
|
@ -2,53 +2,44 @@
|
|||||||
|
|
||||||
# Provides methods for Order
|
# Provides methods for Order
|
||||||
class Orders::OrderService
|
class Orders::OrderService
|
||||||
|
class << self
|
||||||
ORDERS_PER_PAGE = 20
|
ORDERS_PER_PAGE = 20
|
||||||
|
|
||||||
def self.list(filters, current_user)
|
def list(filters, current_user)
|
||||||
orders = Order.where(nil)
|
orders = Order.where(nil)
|
||||||
if filters[:user_id]
|
orders = filter_by_user(orders, filters, current_user)
|
||||||
statistic_profile_id = current_user.statistic_profile.id
|
orders = filter_by_reference(orders, filters, current_user)
|
||||||
if (current_user.member? && current_user.id == filters[:user_id].to_i) || current_user.privileged?
|
orders = filter_by_state(orders, filters)
|
||||||
user = User.find(filters[:user_id])
|
orders = filter_by_period(orders, filters)
|
||||||
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 = orders.where.not(state: 'cart') if current_user.member?
|
orders = orders.where.not(state: 'cart') if current_user.member?
|
||||||
orders = orders.order(created_at: filters[:sort] || 'DESC')
|
orders = orders_ordering(orders, filters)
|
||||||
total_count = orders.count
|
total_count = orders.count
|
||||||
orders = orders.page(filters[:page] || 1).per(ORDERS_PER_PAGE)
|
orders = orders.page(filters[:page] || 1).per(ORDERS_PER_PAGE)
|
||||||
{
|
{
|
||||||
data: orders,
|
data: orders,
|
||||||
page: filters[:page] || 1,
|
page: filters[:page]&.to_i || 1,
|
||||||
total_pages: orders.page(1).per(ORDERS_PER_PAGE).total_pages,
|
total_pages: orders.page(1).per(ORDERS_PER_PAGE).total_pages,
|
||||||
page_size: ORDERS_PER_PAGE,
|
page_size: ORDERS_PER_PAGE,
|
||||||
total_count: total_count
|
total_count: total_count
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.update_state(order, current_user, state, note = nil)
|
def update_state(order, current_user, state, note = nil)
|
||||||
return ::Orders::SetInProgressService.new.call(order, current_user) if state == 'in_progress'
|
case state
|
||||||
return ::Orders::OrderReadyService.new.call(order, current_user, note) if state == 'ready'
|
when 'in_progress'
|
||||||
return ::Orders::OrderCanceledService.new.call(order, current_user) if state == 'canceled'
|
::Orders::SetInProgressService.new.call(order, current_user)
|
||||||
return ::Orders::OrderDeliveredService.new.call(order, current_user) if state == 'delivered'
|
when 'ready'
|
||||||
return ::Orders::OrderRefundedService.new.call(order, current_user) if state == 'refunded'
|
::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
|
end
|
||||||
|
|
||||||
def in_stock?(order, stock_type = 'external')
|
def in_stock?(order, stock_type = 'external')
|
||||||
@ -78,4 +69,50 @@ class Orders::OrderService
|
|||||||
end
|
end
|
||||||
true
|
true
|
||||||
end
|
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
|
||||||
|
|
||||||
|
def orders_ordering(orders, filters)
|
||||||
|
key, order = filters[:sort]&.split('-')
|
||||||
|
key ||= 'created_at'
|
||||||
|
order ||= 'desc'
|
||||||
|
|
||||||
|
orders.order(key => order)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -7,21 +7,22 @@ class ProductService
|
|||||||
|
|
||||||
def list(filters)
|
def list(filters)
|
||||||
products = Product.includes(:product_images)
|
products = Product.includes(:product_images)
|
||||||
if filters[:is_active].present?
|
products = filter_by_active(products, filters)
|
||||||
state = filters[:disabled] == 'false' ? [nil, false] : true
|
products = filter_by_categories(products, filters)
|
||||||
products = products.where(is_active: state)
|
products = filter_by_machines(products, filters)
|
||||||
end
|
products = filter_by_keyword_or_reference(products, filters)
|
||||||
products = products.page(filters[:page]).per(PRODUCTS_PER_PAGE) if filters[:page].present?
|
products = filter_by_stock(products, filters)
|
||||||
products
|
products = products_ordering(products, filters)
|
||||||
end
|
|
||||||
|
|
||||||
def pages(filters)
|
total_count = products.count
|
||||||
products = Product.all
|
products = products.page(filters[:page] || 1).per(PRODUCTS_PER_PAGE)
|
||||||
if filters[:is_active].present?
|
{
|
||||||
state = filters[:disabled] == 'false' ? [nil, false] : true
|
data: products,
|
||||||
products = Product.where(is_active: state)
|
page: filters[:page]&.to_i || 1,
|
||||||
end
|
total_pages: products.page(1).per(PRODUCTS_PER_PAGE).total_pages,
|
||||||
products.page(1).per(PRODUCTS_PER_PAGE).total_pages
|
page_size: PRODUCTS_PER_PAGE,
|
||||||
|
total_count: total_count
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# amount params multiplied by hundred
|
# amount params multiplied by hundred
|
||||||
@ -80,5 +81,48 @@ class ProductService
|
|||||||
product.destroy
|
product.destroy
|
||||||
end
|
end
|
||||||
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
|
||||||
|
|
||||||
|
def products_ordering(products, filters)
|
||||||
|
key, order = filters[:sort]&.split('-')
|
||||||
|
key ||= 'created_at'
|
||||||
|
order ||= 'desc'
|
||||||
|
|
||||||
|
products.order(key => order)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,9 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
json.page @result[:page]
|
json.extract! @result, :page, :total_pages, :page_size, :total_count
|
||||||
json.total_pages @result[:total_pages]
|
|
||||||
json.page_size @result[:page_size]
|
|
||||||
json.total_count @result[:total_count]
|
|
||||||
json.data @result[:data] do |order|
|
json.data @result[:data] do |order|
|
||||||
json.extract! order, :id, :statistic_profile_id, :reference, :state, :created_at, :updated_at
|
json.extract! order, :id, :statistic_profile_id, :reference, :state, :created_at, :updated_at
|
||||||
json.total order.total / 100.0 if order.total.present?
|
json.total order.total / 100.0 if order.total.present?
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
json.total_pages @pages if @pages.present?
|
json.extract! @products, :page, :total_pages, :page_size, :total_count
|
||||||
json.products @products do |product|
|
json.data @products[:data] do |product|
|
||||||
json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :machine_ids,
|
json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :machine_ids,
|
||||||
:low_stock_threshold
|
:low_stock_threshold
|
||||||
json.amount product.amount / 100.0 if product.amount.present?
|
json.amount product.amount / 100.0 if product.amount.present?
|
||||||
|
@ -1933,6 +1933,22 @@ en:
|
|||||||
save: "Save"
|
save: "Save"
|
||||||
required: "This field is required"
|
required: "This field is required"
|
||||||
slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen"
|
slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen"
|
||||||
|
categories_filter:
|
||||||
|
filter_categories: "By categories"
|
||||||
|
filter_apply: "Apply"
|
||||||
|
machines_filter:
|
||||||
|
filter_machines: "By machines"
|
||||||
|
filter_apply: "Apply"
|
||||||
|
keyword_filter:
|
||||||
|
filter_keywords_reference: "By keywords or reference"
|
||||||
|
filter_apply: "Apply"
|
||||||
|
stock_filter:
|
||||||
|
stock_internal: "Private stock"
|
||||||
|
stock_external: "Public stock"
|
||||||
|
filter_stock: "By stock status"
|
||||||
|
filter_stock_from: "From"
|
||||||
|
filter_stock_to: "to"
|
||||||
|
filter_apply: "Apply"
|
||||||
products:
|
products:
|
||||||
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
|
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
|
||||||
all_products: "All products"
|
all_products: "All products"
|
||||||
|
@ -636,3 +636,6 @@ en:
|
|||||||
modal_title: "You have some unsaved changes"
|
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_message: "If you leave this page, your changes will be lost. Are you sure you want to continue?"
|
||||||
confirmation_button: "Yes, don't save"
|
confirmation_button: "Yes, don't save"
|
||||||
|
active_filters_tags:
|
||||||
|
stock_internal: "Private stock"
|
||||||
|
stock_external: "Public stock"
|
||||||
|
8
db/migrate/20220920131912_add_index_on_product_slug.rb
Normal file
8
db/migrate/20220920131912_add_index_on_product_slug.rb
Normal 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
|
23
db/schema.rb
23
db/schema.rb
@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "fuzzystrmatch"
|
enable_extension "fuzzystrmatch"
|
||||||
@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
|
|||||||
enable_extension "unaccent"
|
enable_extension "unaccent"
|
||||||
|
|
||||||
create_table "abuses", id: :serial, force: :cascade do |t|
|
create_table "abuses", id: :serial, force: :cascade do |t|
|
||||||
t.string "signaled_type"
|
|
||||||
t.integer "signaled_id"
|
t.integer "signaled_id"
|
||||||
|
t.string "signaled_type"
|
||||||
t.string "first_name"
|
t.string "first_name"
|
||||||
t.string "last_name"
|
t.string "last_name"
|
||||||
t.string "email"
|
t.string "email"
|
||||||
@ -49,8 +49,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
|
|||||||
t.string "locality"
|
t.string "locality"
|
||||||
t.string "country"
|
t.string "country"
|
||||||
t.string "postal_code"
|
t.string "postal_code"
|
||||||
t.string "placeable_type"
|
|
||||||
t.integer "placeable_id"
|
t.integer "placeable_id"
|
||||||
|
t.string "placeable_type"
|
||||||
t.datetime "created_at"
|
t.datetime "created_at"
|
||||||
t.datetime "updated_at"
|
t.datetime "updated_at"
|
||||||
end
|
end
|
||||||
@ -64,8 +64,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
create_table "assets", id: :serial, force: :cascade do |t|
|
create_table "assets", id: :serial, force: :cascade do |t|
|
||||||
t.string "viewable_type"
|
|
||||||
t.integer "viewable_id"
|
t.integer "viewable_id"
|
||||||
|
t.string "viewable_type"
|
||||||
t.string "attachment"
|
t.string "attachment"
|
||||||
t.string "type"
|
t.string "type"
|
||||||
t.datetime "created_at"
|
t.datetime "created_at"
|
||||||
@ -147,8 +147,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
create_table "credits", id: :serial, force: :cascade do |t|
|
create_table "credits", id: :serial, force: :cascade do |t|
|
||||||
t.string "creditable_type"
|
|
||||||
t.integer "creditable_id"
|
t.integer "creditable_id"
|
||||||
|
t.string "creditable_type"
|
||||||
t.integer "plan_id"
|
t.integer "plan_id"
|
||||||
t.integer "hours"
|
t.integer "hours"
|
||||||
t.datetime "created_at"
|
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|
|
create_table "notifications", id: :serial, force: :cascade do |t|
|
||||||
t.integer "receiver_id"
|
t.integer "receiver_id"
|
||||||
t.string "attached_object_type"
|
|
||||||
t.integer "attached_object_id"
|
t.integer "attached_object_id"
|
||||||
|
t.string "attached_object_type"
|
||||||
t.integer "notification_type_id"
|
t.integer "notification_type_id"
|
||||||
t.boolean "is_read", default: false
|
t.boolean "is_read", default: false
|
||||||
t.datetime "created_at"
|
t.datetime "created_at"
|
||||||
t.datetime "updated_at"
|
t.datetime "updated_at"
|
||||||
t.string "receiver_type"
|
t.string "receiver_type"
|
||||||
t.boolean "is_send", default: false
|
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 ["notification_type_id"], name: "index_notifications_on_notification_type_id"
|
||||||
t.index ["receiver_id"], name: "index_notifications_on_receiver_id"
|
t.index ["receiver_id"], name: "index_notifications_on_receiver_id"
|
||||||
end
|
end
|
||||||
@ -623,8 +623,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do
|
|||||||
create_table "prices", id: :serial, force: :cascade do |t|
|
create_table "prices", id: :serial, force: :cascade do |t|
|
||||||
t.integer "group_id"
|
t.integer "group_id"
|
||||||
t.integer "plan_id"
|
t.integer "plan_id"
|
||||||
t.string "priceable_type"
|
|
||||||
t.integer "priceable_id"
|
t.integer "priceable_id"
|
||||||
|
t.string "priceable_type"
|
||||||
t.integer "amount"
|
t.integer "amount"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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 "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["product_category_id"], name: "index_products_on_product_category_id"
|
t.index ["product_category_id"], name: "index_products_on_product_category_id"
|
||||||
|
t.index ["slug"], name: "index_products_on_slug", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "profile_custom_fields", force: :cascade do |t|
|
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.text "message"
|
||||||
t.datetime "created_at"
|
t.datetime "created_at"
|
||||||
t.datetime "updated_at"
|
t.datetime "updated_at"
|
||||||
t.string "reservable_type"
|
|
||||||
t.integer "reservable_id"
|
t.integer "reservable_id"
|
||||||
|
t.string "reservable_type"
|
||||||
t.integer "nb_reserve_places"
|
t.integer "nb_reserve_places"
|
||||||
t.integer "statistic_profile_id"
|
t.integer "statistic_profile_id"
|
||||||
t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_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|
|
create_table "roles", id: :serial, force: :cascade do |t|
|
||||||
t.string "name"
|
t.string "name"
|
||||||
t.string "resource_type"
|
|
||||||
t.integer "resource_id"
|
t.integer "resource_id"
|
||||||
|
t.string "resource_type"
|
||||||
t.datetime "created_at"
|
t.datetime "created_at"
|
||||||
t.datetime "updated_at"
|
t.datetime "updated_at"
|
||||||
t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id"
|
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.boolean "is_allow_newsletter"
|
||||||
t.inet "current_sign_in_ip"
|
t.inet "current_sign_in_ip"
|
||||||
t.inet "last_sign_in_ip"
|
t.inet "last_sign_in_ip"
|
||||||
t.datetime "validated_at"
|
|
||||||
t.string "mapped_from_sso"
|
t.string "mapped_from_sso"
|
||||||
|
t.datetime "validated_at"
|
||||||
t.index ["auth_token"], name: "index_users_on_auth_token"
|
t.index ["auth_token"], name: "index_users_on_auth_token"
|
||||||
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
|
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
|
||||||
t.index ["email"], name: "index_users_on_email", unique: true
|
t.index ["email"], name: "index_users_on_email", unique: true
|
||||||
|
Loading…
Reference in New Issue
Block a user