1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-11-29 10:24:20 +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 { computePriceWithCoupon } from '../../lib/coupon';
import noImage from '../../../../images/no_image.png';
import Switch from 'react-switch';
declare const Application: IApplication;
@ -112,6 +113,13 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
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
*/
@ -162,6 +170,21 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
<i className="fa fa-trash" />
</FabButton>
</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>
))}
</div>

View File

@ -41,8 +41,11 @@ export const FormSwitch = <TFieldValues, TContext extends object>({ id, label, t
onChangeCb(val);
}}
checked={value as boolean || false}
height={19}
width={40}
height={19}
uncheckedIcon={false}
checkedIcon={false}
handleDiameter={15}
ref={ref}
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 { OrderItem } from './order-item';
import { MemberSelect } from '../user/member-select';
import { FabInput } from '../base/fab-input';
declare const Application: IApplication;

View File

@ -18,6 +18,7 @@ import ProductCategoryAPI from '../../api/product-category';
import MachineAPI from '../../api/machine';
import ProductAPI from '../../api/product';
import { Plus } from 'phosphor-react';
import { ProductStockForm } from './product-stock-form';
interface ProductFormProps {
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 [productCategories, setProductCategories] = useState<selectOption[]>([]);
const [machines, setMachines] = useState<checklistOption[]>([]);
const [stockTab, setStockTab] = useState<boolean>(false);
useEffect(() => {
ProductCategoryAPI.index().then(data => {
@ -235,174 +237,183 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
</div>
</header>
<form className="product-form" onSubmit={onSubmit}>
<div className="subgrid">
<FormInput id="name"
register={register}
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 className='tabs'>
<p className={!stockTab ? 'is-active' : ''} onClick={() => setStockTab(false)}>{t('app.admin.store.product_form.product_parameters')}</p>
<p className={stockTab ? 'is-active' : ''} onClick={() => setStockTab(true)}>{t('app.admin.store.product_form.stock_management')}</p>
</div>
<div className="subgrid">
<FormInput id="slug"
register={register}
rules={{ required: true }}
formState={formState}
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"
{stockTab
? <ProductStockForm product={product} control={control} onError={onError} onSuccess={onSuccess} />
: <section>
<div className="subgrid">
<FormInput id="name"
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 }}
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.quantity_min')} />
</div>}
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.product_images')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_images_info" />
</FabAlert>
<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)}
/>
))}
label={t('app.admin.store.product_form.sku')}
className="span-3" />
</div>
<div className="subgrid">
<FormInput id="slug"
register={register}
rules={{ required: true }}
formState={formState}
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>
<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>
<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}
id="product_category_id"
formState={formState}
label={t('app.admin.store.product_form.linking_product_to_category')} />
</div>
<div className="price-data">
<div className="header-switch">
<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}
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>
<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}
<div>
<h4>{t('app.admin.store.product_form.product_images')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_images_info" />
</FabAlert>
<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}
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)}/>
))}
id="product_category_id"
formState={formState}
label={t('app.admin.store.product_form.linking_product_to_category')} />
</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>
<hr />
<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>
</>
);

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>
))}
</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>
</AccordionItem>
@ -269,7 +269,7 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
</label>
))}
</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>
</AccordionItem>
</div>

View File

@ -13,6 +13,7 @@ import { StoreProductItem } from './store-product-item';
import useCart from '../../hooks/use-cart';
import { emitCustomEvent } from 'react-custom-events';
import { User } from '../../models/user';
import { Order } from '../../models/order';
import { AccordionItem } from './accordion-item';
import { StoreListHeader } from './store-list-header';
@ -20,6 +21,7 @@ declare const Application: IApplication;
interface StoreProps {
onError: (message: string) => void,
onSuccess: (message: string) => void,
currentUser: User,
}
/**
@ -31,7 +33,7 @@ interface StoreProps {
/**
* 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 { cart, setCart } = useCart(currentUser);
@ -138,6 +140,14 @@ const Store: React.FC<StoreProps> = ({ onError, currentUser }) => {
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 (
<div className="store">
<ul className="breadcrumbs">
@ -224,7 +234,7 @@ const Store: React.FC<StoreProps> = ({ onError, currentUser }) => {
/>
<div className="products-grid">
{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>
@ -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 {
id: number,

View File

@ -55,6 +55,24 @@
background-color: var(--gray-soft-light);
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,
.total {
min-width: 10rem;
@ -158,7 +176,9 @@
justify-content: center;
text-transform: uppercase;
&:hover {
color: var(--gray-soft-lightest);
opacity: 0.75;
cursor: pointer;
}
}
}

View File

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

View File

@ -1,5 +1,26 @@
.product-form {
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 {
margin: 0 0 2.4rem;
@include title-base;
@ -30,7 +51,7 @@
}
}
.price-data-header {
.header-switch {
@include grid-col(10);
gap: 3.2rem;
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 {
opacity: 100;
margin-top: 0.8rem;

View File

@ -1964,6 +1964,8 @@ en:
edit_product:
successfully_updated: "The product has been updated."
product_form:
product_parameters: "Product parameters"
stock_management: "Stock management"
name: "Name of product"
sku: "Reference product (SKU)"
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."
add_product_image: "Add an image"
save: "Save"
product_stock_form:
low_stock_threshold: "Define a low stock threshold"
toggle_stock_threshold: "Activate stock threshold"
orders:
heading: "Orders"
create_order: "Create an order"

View File

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