1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-26 20:54:21 +01:00

(wip) Products list filters

This commit is contained in:
vincent 2022-08-05 18:38:54 +02:00
parent d118d045c6
commit 86a40bc096
8 changed files with 486 additions and 167 deletions

View File

@ -54,7 +54,7 @@ const ProductCategories: React.FC<ProductCategoriesProps> = ({ onSuccess, onErro
*/
const refreshCategories = () => {
ProductCategoryAPI.index().then(data => {
// Translate ProductCategory.position to array index
// Map product categories by position
const sortedCategories = data
.filter(c => !c.parent_id)
.sort((a, b) => a.position - b.position);

View File

@ -16,7 +16,6 @@ interface ProductsListProps {
* This component shows a list of all Products
*/
export const ProductsList: React.FC<ProductsListProps> = ({ products, onEdit, onDelete }) => {
console.log('products: ', products);
const { t } = useTranslation('admin');
/**
@ -83,10 +82,12 @@ export const ProductsList: React.FC<ProductsListProps> = ({ products, onEdit, on
<span>{t('app.admin.store.products_list.stock.external')}</span>
<p>{product.stock.external}</p>
</div>
<div>
<p>{FormatLib.price(product.amount)}</p>
<span>/ {t('app.admin.store.products_list.unit')}</span>
</div>
{product.amount &&
<div className='price'>
<p>{FormatLib.price(product.amount)}</p>
<span>/ {t('app.admin.store.products_list.unit')}</span>
</div>
}
</div>
<div className='actions'>
<div className='manage'>

View File

@ -1,13 +1,20 @@
// TODO: Remove next eslint-disable
/* eslint-disable @typescript-eslint/no-unused-vars */
import React, { useState, useEffect } from 'react';
import { useImmer } from 'use-immer';
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 { ProductCategory } from '../../models/product-category';
import { FabButton } from '../base/fab-button';
import { ProductsList } from './products-list';
import { Product } from '../../models/product';
import ProductAPI from '../../api/product';
import { X } from 'phosphor-react';
import ProductCategoryAPI from '../../api/product-category';
import MachineAPI from '../../api/machine';
import { CaretDown, X } from 'phosphor-react';
import Switch from 'react-switch';
declare const Application: IApplication;
@ -23,13 +30,45 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [products, setProducts] = useState<Array<Product>>([]);
const [filteredProductsList, setFilteredProductList] = useImmer<Array<Product>>([]);
const [filterVisible, setFilterVisible] = useState<boolean>(false);
const [clearFilters, setClearFilters] = useState<boolean>(false);
const [filters, setFilters] = useImmer<Filters>(initFilters);
const [productCategories, setProductCategories] = useState<ProductCategory[]>([]);
const [machines, setMachines] = useState<checklistOption[]>([]);
useEffect(() => {
ProductAPI.index().then(data => {
setProducts(data);
setFilteredProductList(data);
});
}, []);
useEffect(() => {
ProductCategoryAPI.index().then(data => {
// Map product categories by position
const sortedCategories = data
.filter(c => !c.parent_id)
.sort((a, b) => a.position - b.position);
const childrenCategories = data
.filter(c => typeof c.parent_id === 'number')
.sort((a, b) => b.position - a.position);
childrenCategories.forEach(c => {
const parentIndex = sortedCategories.findIndex(i => i.id === c.parent_id);
sortedCategories.splice(parentIndex + 1, 0, c);
});
setProductCategories(sortedCategories);
}).catch(onError);
MachineAPI.index({ disabled: false }).then(data => {
setMachines(buildChecklistOptions(data));
}).catch(onError);
}, []);
useEffect(() => {
applyFilters();
setClearFilters(false);
}, [filterVisible, clearFilters]);
/**
* Goto edit product page
*/
@ -58,6 +97,71 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
window.location.href = '/#!/admin/store/products/new';
};
/**
* Filter: toggle hidden products visibility
*/
const toggleVisible = (checked: boolean) => {
setFilterVisible(checked);
};
/**
* Filter: by categories
*/
const handleSelectCategory = (c: ProductCategory, checked) => {
let list = [...filters.categories];
const children = productCategories
.filter(el => el.parent_id === c.id)
.map(el => el.id);
const siblings = productCategories
.filter(el => el.parent_id === c.parent_id && el.parent_id !== null);
if (checked) {
list.push(c.id);
if (children.length) {
const unic = Array.from(new Set([...list, ...children]));
list = [...unic];
}
if (siblings.length && siblings.every(el => list.includes(el.id))) {
list.push(siblings[0].parent_id);
}
} else {
list.splice(list.indexOf(c.id), 1);
if (c.parent_id && list.includes(c.parent_id)) {
list.splice(list.indexOf(c.parent_id), 1);
}
if (children.length) {
children.forEach(child => {
list.splice(list.indexOf(child), 1);
});
}
}
setFilters(draft => {
return { ...draft, categories: list };
});
};
/**
* Apply filters
*/
const applyFilters = () => {
let updatedList = [...products];
if (filterVisible) {
updatedList = updatedList.filter(p => p.is_active);
}
if (filters.categories.length) {
updatedList = updatedList.filter(p => filters.categories.includes(p.product_category_id));
}
setFilteredProductList(updatedList);
};
/**
* Clear filters
*/
const clearAllFilters = () => {
setFilters(initFilters);
setClearFilters(true);
};
return (
<div className='products'>
<header>
@ -69,38 +173,79 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
<div className='layout'>
<div className='products-filters span-3'>
<header>
<h3>Filtrer</h3>
<h3>{t('app.admin.store.products.filter')}</h3>
<div className='grpBtn'>
<FabButton className="is-black">Clear</FabButton>
<FabButton onClick={clearAllFilters} className="is-black">{t('app.admin.store.products.filter_clear')}</FabButton>
</div>
</header>
<div className='accordion'>
<div className='accordion-item'>
<input type="checkbox" defaultChecked />
<header>{t('app.admin.store.products.filter_categories')}
<CaretDown size={16} weight="bold" /></header>
<div className='content'>
<div className="list scrollbar">
{productCategories.map(pc => (
<label key={pc.id} className={pc.parent_id ? 'offset' : ''}>
<input type="checkbox" checked={filters.categories.includes(pc.id)} onChange={(event) => handleSelectCategory(pc, event.target.checked)} />
<p key={pc.id}>{pc.name}</p>
</label>
))}
</div>
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
</div>
</div>
<div className='accordion-item'>
<input type="checkbox" defaultChecked />
<header>{t('app.admin.store.products.filter_machines')}
<CaretDown size={16} weight="bold" /></header>
<div className='content'>
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Minus saepe aperiam autem eum magni nihil odio totam enim similique error! Est veritatis illum adipisci aspernatur sit nulla voluptate. Exercitationem, totam!
<FabButton className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
</div>
</div>
</div>
</div>
<div className='products-list span-7'>
<div className='status'>
<div className='count'>
<p>Result count: <span>{products.length}</span></p>
<p>{t('app.admin.store.products.result_count')}<span>{filteredProductsList.length}</span></p>
</div>
<div className="">
<div className="display">
<div className='sort'>
<p>Display options:</p>
<p>{t('app.admin.store.products.display_options')}</p>
<select>
<option value="A">A</option>
<option value="B">B</option>
</select>
</div>
<div className='visibility'>
<label>
<span>{t('app.admin.store.products.visible_only')}</span>
<Switch
checked={filterVisible}
onChange={(checked) => toggleVisible(checked)}
width={40}
height={19}
uncheckedIcon={false}
checkedIcon={false}
handleDiameter={15} />
</label>
</div>
</div>
</div>
<div className='features'>
<div className='features-item'>
<p>feature name</p>
<button><X size={16} /></button>
<button><X size={16} weight="light" /></button>
</div>
<div className='features-item'>
<p>long feature name</p>
<button><X size={16} /></button>
<button><X size={16} weight="light" /></button>
</div>
</div>
<ProductsList
products={products}
products={filteredProductsList}
onEdit={editProduct}
onDelete={deleteProduct}
/>
@ -119,3 +264,44 @@ const ProductsWrapper: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
};
Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError']));
/**
* Option format, expected by checklist
*/
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 };
});
};
const initFilters: Filters = {
categories: [],
machines: [],
keywords: [],
internalStock: {
from: 0,
to: null
},
externalStock: {
from: 0,
to: null
}
};
interface Stock {
from: number,
to: number
}
interface Filters {
categories: number[],
machines: number[],
keywords: string[],
internalStock: Stock,
externalStock: Stock
}

View File

@ -91,6 +91,9 @@
@import "modules/store/_utilities";
@import "modules/store/manage-product-category";
@import "modules/store/product-categories";
@import "modules/store/product-form";
@import "modules/store/products-filters";
@import "modules/store/products-list";
@import "modules/store/products";
@import "modules/subscriptions/free-extend-modal";
@import "modules/subscriptions/renew-modal";
@ -104,7 +107,6 @@
@import "modules/user/gender-input";
@import "modules/user/user-profile-form";
@import "modules/user/user-validation";
@import "modules/store/product-form";
@import "modules/abuses";
@import "modules/cookies";

View File

@ -0,0 +1,86 @@
.products-filters {
padding-top: 1.6rem;
border-top: 1px solid var(--gray-soft-dark);
.accordion {
&-item:not(:last-of-type) {
margin-bottom: 1.6rem;
border-bottom: 1px solid var(--gray-soft-darkest);
}
&-item {
position: relative;
padding-bottom: 1.6rem;
& > input[type=checkbox] {
position: absolute;
width: 100%;
z-index: 1;
opacity: 0;
cursor: pointer;
}
& > input[type=checkbox]:checked ~ .content {
max-height: 0;
}
& > input[type=checkbox]:checked ~ header svg {
transform: rotateZ(180deg);
}
header {
width: 100%;
padding: 0;
display: flex;
justify-content: space-between;
align-items: center;
background: none;
border: none;
@include text-base(600);
svg { transition: transform 250ms ease-in-out; }
}
.content {
max-height: 24rem;
padding-top: 1.6rem;
display: flex;
flex-direction: column;
align-items: stretch;
transition: max-height 500ms ease-in-out;
overflow: hidden;
.list {
overflow: hidden auto;
label {
display: flex;
align-items: center;
input[type=checkbox] { margin: 0 0.8rem 0 0; }
p { margin: 0; }
&.offset { margin-left: 1.6rem; }
}
}
button {
margin-top: 0.8rem;
justify-content: center;
}
}
}
}
}
// Custom scrollbar
.scrollbar {
&::-webkit-scrollbar-track
{
border-radius: 6px;
background-color: #d9d9d9;
}
&::-webkit-scrollbar
{
width: 12px;
background-color: #ffffff;
}
&::-webkit-scrollbar-thumb
{
border-radius: 6px;
background-color: #2d2d2d;
border: 2px solid #d9d9d9
}
}

View File

@ -0,0 +1,181 @@
.products-list {
.status {
padding: 1.6rem 2.4rem;
display: flex;
justify-content: space-between;
background-color: var(--gray-soft);
border-radius: var(--border-radius);
p { margin: 0; }
.count {
p {
display: flex;
align-items: center;
@include text-sm;
span {
margin-left: 1.6rem;
@include text-lg(600);
}
}
}
.display {
display: flex;
align-items: center;
& > *:not(:first-child) {
margin-left: 2rem;
padding-left: 2rem;
border-left: 1px solid var(--gray-hard-darkest);
}
.sort {
display: flex;
align-items: center;
}
.visibility {
display: flex;
align-items: center;
label {
margin: 0;
display: flex;
align-items: center;
font-weight: 400;
cursor: pointer;
span {
margin-right: 1rem;
}
}
}
}
}
.features {
margin: 2.4rem 0 1.6rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.6rem 2.4rem;
&-item {
padding-left: 1.6rem;
display: flex;
align-items: center;
background-color: var(--information-light);
border-radius: 100px;
color: var(--information-dark);
overflow: hidden;
p { margin: 0; }
button {
width: 3.2rem;
height: 3.2rem;
margin-left: 0.8rem;
display: flex;
align-items: center;
background: none;
border: none;
}
}
}
&-item {
--status-color: var(--gray-hard-darkest);
&.low { --status-color: var(--alert-light); }
&.out-of-stock { --status-color: var(--alert); }
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.6rem 0.8rem;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
background-color: var(--gray-soft-lightest);
&.out-of-stock { border-color: var(--status-color); }
&:not(:first-child) {
margin-top: 1.6rem;
}
.itemInfo {
min-width: 20ch;
flex: 1;
display: flex;
align-items: center;
&-thumbnail {
width: 4.8rem;
height: 4.8rem;
margin-right: 1.6rem;
object-fit: cover;
border-radius: var(--border-radius);
background-color: var(--gray-soft);
}
&-name {
margin: 0;
@include text-base;
font-weight: 600;
color: var(--gray-hard-darkest);
}
}
.details {
display: grid;
grid-template-columns: 120px repeat(2, minmax(min-content, 120px)) 120px;
justify-items: center;
align-items: center;
gap: 1.6rem;
margin-left: auto;
margin-right: 4rem;
p {
margin: 0;
@include text-base(600);
}
.visibility {
justify-self: center;
padding: 0.4rem 0.8rem;
display: flex;
align-items: center;
background-color: var(--gray-soft-light);
border-radius: var(--border-radius);
&::before {
flex-shrink: 0;
margin-right: 1rem;
content: "";
width: 1rem;
height: 1rem;
background-color: var(--gray-hard);
border-radius: 50%;
}
&.is-active::before {
background-color: var(--success);
}
}
.stock {
display: flex;
flex-direction: column;
color: var(--status-color);
span { @include text-xs; }
}
.price {
justify-self: flex-end;
}
}
.actions {
display: flex;
justify-content: flex-end;
align-items: center;
.manage {
overflow: hidden;
display: flex;
border-radius: var(--border-radius-sm);
button {
@include btn;
border-radius: 0;
color: var(--gray-soft-lightest);
&:hover { opacity: 0.75; }
}
.edit-btn {background: var(--gray-hard-darkest) }
.delete-btn {background: var(--main) }
}
}
}
}

View File

@ -72,155 +72,6 @@
.layout {
align-items: flex-start;
}
&-filters {
padding-top: 1.6rem;
border-top: 1px solid var(--gray-soft-dark);
}
&-list {
.status {
padding: 1.6rem 2.4rem;
display: flex;
justify-content: space-between;
background-color: var(--gray-soft);
border-radius: var(--border-radius);
p { margin: 0; }
.count {
p {
display: flex;
align-items: center;
@include text-sm;
span {
margin-left: 1.6rem;
@include text-lg(600);
}
}
}
}
.features {
margin: 2.4rem 0 1.6rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.6rem 2.4rem;
&-item {
padding-left: 1.6rem;
display: flex;
align-items: center;
background-color: var(--information-light);
border-radius: 100px;
color: var(--information-dark);
p { margin: 0; }
button {
width: 3.2rem;
height: 3.2rem;
display: flex;
align-items: center;
background: none;
border: none;
}
}
}
&-item {
--status-color: var(--gray-hard-darkest);
&.low { --status-color: var(--alert-light); }
&.out-of-stock { --status-color: var(--alert); }
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.6rem 0.8rem;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
background-color: var(--gray-soft-lightest);
&.out-of-stock { border-color: var(--status-color); }
&:not(:first-child) {
margin-top: 1.6rem;
}
.itemInfo {
min-width: 20ch;
display: flex;
align-items: center;
&-thumbnail {
width: 4.8rem;
height: 4.8rem;
margin-right: 1.6rem;
object-fit: cover;
border-radius: var(--border-radius);
background-color: var(--gray-soft);
}
&-name {
margin: 0;
@include text-base;
font-weight: 600;
color: var(--gray-hard-darkest);
}
}
.details {
display: grid;
grid-template-columns: 140px repeat(3, minmax(120px, 1fr));
justify-items: flex-start;
align-items: center;
gap: 1rem;
margin-left: auto;
margin-right: 4rem;
p {
margin: 0;
@include text-base(600);
}
.visibility {
justify-self: center;
padding: 0.4rem 0.8rem;
display: flex;
align-items: center;
background-color: var(--gray-soft-light);
border-radius: var(--border-radius);
&::before {
flex-shrink: 0;
margin-right: 1rem;
content: "";
width: 1rem;
height: 1rem;
background-color: var(--gray-hard);
border-radius: 50%;
}
&.is-active::before {
background-color: var(--success);
}
}
.stock {
display: flex;
flex-direction: column;
color: var(--status-color);
span { @include text-xs; }
}
}
.actions {
display: flex;
justify-content: flex-end;
align-items: center;
.manage {
overflow: hidden;
display: flex;
border-radius: var(--border-radius-sm);
button {
@include btn;
border-radius: 0;
color: var(--gray-soft-lightest);
&:hover { opacity: 0.75; }
}
.edit-btn {background: var(--gray-hard-darkest) }
.delete-btn {background: var(--main) }
}
}
}
}
}
.new-product,

View File

@ -1933,6 +1933,18 @@ en:
create_a_product: "Create a product"
successfully_deleted: "The product has been successfully deleted"
unable_to_delete: "Unable to delete the product: "
filter: "Filter"
filter_clear: "Clear all"
filter_apply: "Apply"
filter_categories: "By categories"
filter_machines: "By machines"
filter_keywords_reference: "By keywords or reference"
filter_stock: "By stock status"
filter_stock_from: "From"
filter_stock_to: "to"
result_count: "Result count:"
display_options: "Display options:"
visible_only: "Visible products only"
products_list:
visible: "visible"
hidden: "hidden"