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

stock (wip)

This commit is contained in:
vincent 2022-09-02 18:17:15 +02:00
parent a5a45ee1ce
commit f21a68593a
13 changed files with 314 additions and 163 deletions

View File

@ -16,6 +16,7 @@ import { CouponInput } from '../coupon/coupon-input';
import { Coupon } from '../../models/coupon'; import { Coupon } from '../../models/coupon';
import { computePriceWithCoupon } from '../../lib/coupon'; import { computePriceWithCoupon } from '../../lib/coupon';
import noImage from '../../../../images/no_image.png'; import noImage from '../../../../images/no_image.png';
import Switch from 'react-switch';
declare const Application: IApplication; declare const Application: IApplication;
@ -112,6 +113,13 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
return cart && cart.order_items_attributes.length === 0; return cart && cart.order_items_attributes.length === 0;
}; };
/**
* Toggle product offer
*/
const onSwitch = (product, checked: boolean) => {
console.log('Offer ', product.orderable_name, ': ', checked);
};
/** /**
* Apply coupon to current cart * Apply coupon to current cart
*/ */
@ -162,6 +170,21 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
<i className="fa fa-trash" /> <i className="fa fa-trash" />
</FabButton> </FabButton>
</div> </div>
{isPrivileged() &&
<div className='offer'>
<label>
<span>Offer the product</span>
<Switch
checked={item.is_offered}
onChange={(checked) => onSwitch(item, checked)}
width={40}
height={19}
uncheckedIcon={false}
checkedIcon={false}
handleDiameter={15} />
</label>
</div>
}
</article> </article>
))} ))}
</div> </div>

View File

@ -41,8 +41,11 @@ export const FormSwitch = <TFieldValues, TContext extends object>({ id, label, t
onChangeCb(val); onChangeCb(val);
}} }}
checked={value as boolean || false} checked={value as boolean || false}
height={19}
width={40} width={40}
height={19}
uncheckedIcon={false}
checkedIcon={false}
handleDiameter={15}
ref={ref} ref={ref}
disabled={typeof disabled === 'function' ? disabled(id) : disabled} /> disabled={typeof disabled === 'function' ? disabled(id) : disabled} />
} /> } />

View File

@ -9,6 +9,7 @@ import { StoreListHeader } from './store-list-header';
import { AccordionItem } from './accordion-item'; import { AccordionItem } from './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 { FabInput } from '../base/fab-input';
declare const Application: IApplication; declare const Application: IApplication;

View File

@ -18,6 +18,7 @@ import ProductCategoryAPI from '../../api/product-category';
import MachineAPI from '../../api/machine'; import MachineAPI from '../../api/machine';
import ProductAPI from '../../api/product'; import ProductAPI from '../../api/product';
import { Plus } from 'phosphor-react'; import { Plus } from 'phosphor-react';
import { ProductStockForm } from './product-stock-form';
interface ProductFormProps { interface ProductFormProps {
product: Product, product: Product,
@ -48,6 +49,7 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
const [isActivePrice, setIsActivePrice] = useState<boolean>(product.id && _.isFinite(product.amount) && product.amount > 0); const [isActivePrice, setIsActivePrice] = useState<boolean>(product.id && _.isFinite(product.amount) && product.amount > 0);
const [productCategories, setProductCategories] = useState<selectOption[]>([]); const [productCategories, setProductCategories] = useState<selectOption[]>([]);
const [machines, setMachines] = useState<checklistOption[]>([]); const [machines, setMachines] = useState<checklistOption[]>([]);
const [stockTab, setStockTab] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
ProductCategoryAPI.index().then(data => { ProductCategoryAPI.index().then(data => {
@ -235,174 +237,183 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
</div> </div>
</header> </header>
<form className="product-form" onSubmit={onSubmit}> <form className="product-form" onSubmit={onSubmit}>
<div className="subgrid"> <div className='tabs'>
<FormInput id="name" <p className={!stockTab ? 'is-active' : ''} onClick={() => setStockTab(false)}>{t('app.admin.store.product_form.product_parameters')}</p>
register={register} <p className={stockTab ? 'is-active' : ''} onClick={() => setStockTab(true)}>{t('app.admin.store.product_form.stock_management')}</p>
rules={{ required: true }}
formState={formState}
onChange={handleNameChange}
label={t('app.admin.store.product_form.name')}
className="span-7" />
<FormInput id="sku"
register={register}
formState={formState}
label={t('app.admin.store.product_form.sku')}
className="span-3" />
</div> </div>
<div className="subgrid"> {stockTab
<FormInput id="slug" ? <ProductStockForm product={product} control={control} onError={onError} onSuccess={onSuccess} />
register={register} : <section>
rules={{ required: true }} <div className="subgrid">
formState={formState} <FormInput id="name"
label={t('app.admin.store.product_form.slug')}
className='span-7' />
<FormSwitch control={control}
id="is_active"
formState={formState}
label={t('app.admin.store.product_form.is_show_in_store')}
tooltip={t('app.admin.store.product_form.active_price_info')}
className='span-3' />
</div>
<hr />
<div className="price-data">
<div className="price-data-header">
<h4 className='span-7'>{t('app.admin.store.product_form.price_and_rule_of_selling_product')}</h4>
<FormSwitch control={control}
id="is_active_price"
label={t('app.admin.store.product_form.is_active_price')}
defaultValue={isActivePrice}
onChange={toggleIsActivePrice}
className='span-3' />
</div>
{isActivePrice && <div className="price-data-content">
<FormInput id="amount"
type="number"
register={register} register={register}
rules={{ required: true, min: 0.01 }}
step={0.01}
formState={formState}
label={t('app.admin.store.product_form.price')} />
<FormInput id="quantity_min"
type="number"
rules={{ required: true }} rules={{ required: true }}
formState={formState}
onChange={handleNameChange}
label={t('app.admin.store.product_form.name')}
className="span-7" />
<FormInput id="sku"
register={register} register={register}
formState={formState} formState={formState}
label={t('app.admin.store.product_form.quantity_min')} /> label={t('app.admin.store.product_form.sku')}
</div>} className="span-3" />
</div> </div>
<div className="subgrid">
<hr /> <FormInput id="slug"
register={register}
<div> rules={{ required: true }}
<h4>{t('app.admin.store.product_form.product_images')}</h4> formState={formState}
<FabAlert level="warning"> label={t('app.admin.store.product_form.slug')}
<HtmlTranslate trKey="app.admin.store.product_form.product_images_info" /> className='span-7' />
</FabAlert> <FormSwitch control={control}
<div className="product-images"> id="is_active"
<div className="list"> formState={formState}
{output.product_images_attributes.map((image, i) => ( label={t('app.admin.store.product_form.is_show_in_store')}
<FormImageUpload key={i} tooltip={t('app.admin.store.product_form.active_price_info')}
defaultImage={image} className='span-3' />
id={`product_images_attributes[${i}]`}
accept="image/*"
size="small"
register={register}
setValue={setValue}
formState={formState}
className={image._destroy ? 'hidden' : ''}
mainOption={true}
onFileRemove={handleRemoveProductImage(i)}
onFileIsMain={handleSetMainImage(i)}
/>
))}
</div> </div>
<FabButton
onClick={addProductImage}
className='is-info'
icon={<Plus size={24} />}>
{t('app.admin.store.product_form.add_product_image')}
</FabButton>
</div>
</div>
<hr /> <hr />
<div> <div className="price-data">
<h4>{t('app.admin.store.product_form.assigning_category')}</h4> <div className="header-switch">
<FabAlert level="warning"> <h4 className='span-7'>{t('app.admin.store.product_form.price_and_rule_of_selling_product')}</h4>
<HtmlTranslate trKey="app.admin.store.product_form.assigning_category_info" /> <FormSwitch control={control}
</FabAlert> id="is_active_price"
<FormSelect options={productCategories} label={t('app.admin.store.product_form.is_active_price')}
control={control} defaultValue={isActivePrice}
id="product_category_id" onChange={toggleIsActivePrice}
formState={formState} className='span-3' />
label={t('app.admin.store.product_form.linking_product_to_category')} /> </div>
</div> {isActivePrice && <div className="price-data-content">
<FormInput id="amount"
type="number"
register={register}
rules={{ required: true, min: 0.01 }}
step={0.01}
formState={formState}
label={t('app.admin.store.product_form.price')} />
<FormInput id="quantity_min"
type="number"
rules={{ required: true }}
register={register}
formState={formState}
label={t('app.admin.store.product_form.quantity_min')} />
</div>}
</div>
<hr /> <hr />
<div> <div>
<h4>{t('app.admin.store.product_form.assigning_machines')}</h4> <h4>{t('app.admin.store.product_form.product_images')}</h4>
<FabAlert level="warning"> <FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.assigning_machines_info" /> <HtmlTranslate trKey="app.admin.store.product_form.product_images_info" />
</FabAlert> </FabAlert>
<FormChecklist options={machines} <div className="product-images">
<div className="list">
{output.product_images_attributes.map((image, i) => (
<FormImageUpload key={i}
defaultImage={image}
id={`product_images_attributes[${i}]`}
accept="image/*"
size="small"
register={register}
setValue={setValue}
formState={formState}
className={image._destroy ? 'hidden' : ''}
mainOption={true}
onFileRemove={handleRemoveProductImage(i)}
onFileIsMain={handleSetMainImage(i)}
/>
))}
</div>
<FabButton
onClick={addProductImage}
className='is-info'
icon={<Plus size={24} />}>
{t('app.admin.store.product_form.add_product_image')}
</FabButton>
</div>
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.assigning_category')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.assigning_category_info" />
</FabAlert>
<FormSelect options={productCategories}
control={control} control={control}
id="machine_ids" id="product_category_id"
formState={formState} /> formState={formState}
</div> label={t('app.admin.store.product_form.linking_product_to_category')} />
<hr />
<div>
<h4>{t('app.admin.store.product_form.product_description')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_description_info" />
</FabAlert>
<FormRichText control={control}
heading
bulletList
blockquote
link
limit={6000}
id="description" />
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.product_files')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_files_info" />
</FabAlert>
<div className="product-documents">
<div className="list">
{output.product_files_attributes.map((file, i) => (
<FormFileUpload key={i}
defaultFile={file}
id={`product_files_attributes[${i}]`}
accept="application/pdf"
register={register}
setValue={setValue}
formState={formState}
className={file._destroy ? 'hidden' : ''}
onFileRemove={handleRemoveProductFile(i)}/>
))}
</div> </div>
<FabButton
onClick={addProductFile}
className='is-info'
icon={<Plus size={24} />}>
{t('app.admin.store.product_form.add_product_file')}
</FabButton>
</div>
</div>
<div className="main-actions"> <hr />
<FabButton type="submit" className="main-action-btn">{t('app.admin.store.product_form.save')}</FabButton>
</div> <div>
<h4>{t('app.admin.store.product_form.assigning_machines')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.assigning_machines_info" />
</FabAlert>
<FormChecklist options={machines}
control={control}
id="machine_ids"
formState={formState} />
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.product_description')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_description_info" />
</FabAlert>
<FormRichText control={control}
heading
bulletList
blockquote
link
limit={6000}
id="description" />
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.product_files')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_files_info" />
</FabAlert>
<div className="product-documents">
<div className="list">
{output.product_files_attributes.map((file, i) => (
<FormFileUpload key={i}
defaultFile={file}
id={`product_files_attributes[${i}]`}
accept="application/pdf"
register={register}
setValue={setValue}
formState={formState}
className={file._destroy ? 'hidden' : ''}
onFileRemove={handleRemoveProductFile(i)}/>
))}
</div>
<FabButton
onClick={addProductFile}
className='is-info'
icon={<Plus size={24} />}>
{t('app.admin.store.product_form.add_product_file')}
</FabButton>
</div>
</div>
<div className="main-actions">
<FabButton type="submit" className="main-action-btn">{t('app.admin.store.product_form.save')}</FabButton>
</div>
</section>
}
</form> </form>
</> </>
); );

View File

@ -0,0 +1,44 @@
import React from 'react';
import { Product } from '../../models/product';
import { useTranslation } from 'react-i18next';
import { Control } from 'react-hook-form';
import { FormSwitch } from '../form/form-switch';
interface ProductStockFormProps {
product: Product,
control: Control,
onSuccess: (product: Product) => void,
onError: (message: string) => void,
}
/**
* Form tab to manage product's stock
*/
export const ProductStockForm: React.FC<ProductStockFormProps> = ({ product, control, onError, onSuccess }) => {
const { t } = useTranslation('admin');
/**
* Toggle stock threshold
*/
const toggleStockThreshold = (checked: boolean) => {
console.log('Stock threshold:', checked);
};
return (
<section>
<h4>Stock à jour <span>00/00/0000 - 00H30</span></h4>
<div></div>
<hr />
<div className="header-switch">
<h4 className='span-7'>{t('app.admin.store.product_stock_form.low_stock_threshold')}</h4>
<FormSwitch control={control}
id="is_active_threshold"
label={t('app.admin.store.product_stock_form.toggle_stock_threshold')}
defaultValue={false}
onChange={toggleStockThreshold}
className='span-3' />
</div>
</section>
);
};

View File

@ -251,7 +251,7 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
</label> </label>
))} ))}
</div> </div>
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton> <FabButton onClick={() => setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
</div> </div>
</AccordionItem> </AccordionItem>
@ -269,7 +269,7 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
</label> </label>
))} ))}
</div> </div>
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton> <FabButton onClick={() => setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
</div> </div>
</AccordionItem> </AccordionItem>
</div> </div>

View File

@ -13,6 +13,7 @@ import { StoreProductItem } from './store-product-item';
import useCart from '../../hooks/use-cart'; import useCart from '../../hooks/use-cart';
import { emitCustomEvent } from 'react-custom-events'; import { emitCustomEvent } from 'react-custom-events';
import { User } from '../../models/user'; import { User } from '../../models/user';
import { Order } from '../../models/order';
import { AccordionItem } from './accordion-item'; import { AccordionItem } from './accordion-item';
import { StoreListHeader } from './store-list-header'; import { StoreListHeader } from './store-list-header';
@ -20,6 +21,7 @@ declare const Application: IApplication;
interface StoreProps { interface StoreProps {
onError: (message: string) => void, onError: (message: string) => void,
onSuccess: (message: string) => void,
currentUser: User, currentUser: User,
} }
/** /**
@ -31,7 +33,7 @@ interface StoreProps {
/** /**
* This component shows public store * This component shows public store
*/ */
const Store: React.FC<StoreProps> = ({ onError, currentUser }) => { const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser }) => {
const { t } = useTranslation('public'); const { t } = useTranslation('public');
const { cart, setCart } = useCart(currentUser); const { cart, setCart } = useCart(currentUser);
@ -138,6 +140,14 @@ const Store: React.FC<StoreProps> = ({ onError, currentUser }) => {
console.log('Display in stock only:', checked); console.log('Display in stock only:', checked);
}; };
/**
* Add product to the cart
*/
const addToCart = (cart: Order) => {
setCart(cart);
onSuccess(t('app.public.store.add_to_cart_success'));
};
return ( return (
<div className="store"> <div className="store">
<ul className="breadcrumbs"> <ul className="breadcrumbs">
@ -224,7 +234,7 @@ const Store: React.FC<StoreProps> = ({ onError, currentUser }) => {
/> />
<div className="products-grid"> <div className="products-grid">
{products.map((product) => ( {products.map((product) => (
<StoreProductItem key={product.id} product={product} cart={cart} onSuccessAddProductToCart={setCart} /> <StoreProductItem key={product.id} product={product} cart={cart} onSuccessAddProductToCart={addToCart} />
))} ))}
</div> </div>
</div> </div>
@ -253,7 +263,7 @@ const StoreWrapper: React.FC<StoreProps> = (props) => {
); );
}; };
Application.Components.component('store', react2angular(StoreWrapper, ['onError', 'currentUser'])); Application.Components.component('store', react2angular(StoreWrapper, ['onError', 'onSuccess', 'currentUser']));
interface ActiveCategory { interface ActiveCategory {
id: number, id: number,

View File

@ -55,6 +55,24 @@
background-color: var(--gray-soft-light); background-color: var(--gray-soft-light);
border-radius: var(--border-radius); border-radius: var(--border-radius);
} }
.offer {
align-self: stretch;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.8rem 1.6rem;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
label {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0;
@include text-base;
cursor: pointer;
span { margin-right: 0.8rem; }
}
}
.price, .price,
.total { .total {
min-width: 10rem; min-width: 10rem;
@ -158,7 +176,9 @@
justify-content: center; justify-content: center;
text-transform: uppercase; text-transform: uppercase;
&:hover { &:hover {
color: var(--gray-soft-lightest);
opacity: 0.75; opacity: 0.75;
cursor: pointer;
} }
} }
} }

View File

@ -32,7 +32,7 @@
background-color: var(--information-lightest); background-color: var(--information-lightest);
color: var(--information); color: var(--information);
border: 1px solid var(--information); border: 1px solid var(--information);
border-radius: 8px; border-radius: var(--border-radius);
font-size: 14px; font-size: 14px;
font-weight: normal; font-weight: normal;
line-height: 1.2em; line-height: 1.2em;

View File

@ -1,5 +1,26 @@
.product-form { .product-form {
grid-column: 2 / -2; grid-column: 2 / -2;
.tabs {
display: flex;
justify-content: space-between;
p {
flex: 1;
margin-bottom: 4rem;
padding: 0.8rem;
text-align: center;
color: var(--main);
border-bottom: 1px solid var(--gray-soft-dark);
&.is-active {
color: var(--gray-hard-dark);
border: 1px solid var(--gray-soft-dark);
border-bottom: none;
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
}
&:hover { cursor: pointer; }
}
}
h4 { h4 {
margin: 0 0 2.4rem; margin: 0 0 2.4rem;
@include title-base; @include title-base;
@ -30,7 +51,7 @@
} }
} }
.price-data-header { .header-switch {
@include grid-col(10); @include grid-col(10);
gap: 3.2rem; gap: 3.2rem;
align-items: center; align-items: center;

View File

@ -123,6 +123,18 @@
} }
} }
input[type="text"] {
width: 100%;
min-height: 4rem;
padding: 0 0.8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius-sm);
@include text-base;
}
button { button {
opacity: 100; opacity: 100;
margin-top: 0.8rem; margin-top: 0.8rem;

View File

@ -1964,6 +1964,8 @@ en:
edit_product: edit_product:
successfully_updated: "The product has been updated." successfully_updated: "The product has been updated."
product_form: product_form:
product_parameters: "Product parameters"
stock_management: "Stock management"
name: "Name of product" name: "Name of product"
sku: "Reference product (SKU)" sku: "Reference product (SKU)"
slug: "Name of URL" slug: "Name of URL"
@ -1987,6 +1989,9 @@ en:
product_images_info: "<strong>Advice</strong></br>We advise you to use a square format, jpg or png, for jpgs, please use white for the background colour. The main visual will be the visual presented first in the product sheet." product_images_info: "<strong>Advice</strong></br>We advise you to use a square format, jpg or png, for jpgs, please use white for the background colour. The main visual will be the visual presented first in the product sheet."
add_product_image: "Add an image" add_product_image: "Add an image"
save: "Save" save: "Save"
product_stock_form:
low_stock_threshold: "Define a low stock threshold"
toggle_stock_threshold: "Activate stock threshold"
orders: orders:
heading: "Orders" heading: "Orders"
create_order: "Create an order" create_order: "Create an order"

View File

@ -383,6 +383,7 @@ en:
store: store:
fablab_store: "FabLab Store" fablab_store: "FabLab Store"
unexpected_error_occurred: "An unexpected error occurred. Please try again later." unexpected_error_occurred: "An unexpected error occurred. Please try again later."
add_to_cart_success: "Product added to the cart."
products: products:
all_products: "All the products" all_products: "All the products"
filter: "Filter" filter: "Filter"