mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-02-19 13:54:25 +01:00
(feat) display/remove ongoing stocks operations
This commit is contained in:
parent
c968f7b1aa
commit
f705f71c4f
@ -8,7 +8,7 @@ export default class SettingAPI {
|
||||
return res?.data?.setting;
|
||||
}
|
||||
|
||||
static async query (names: Array<SettingName>): Promise<Map<SettingName, string>> {
|
||||
static async query (names: readonly SettingName[]): Promise<Map<SettingName, string>> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('names', `['${names.join("','")}']`);
|
||||
|
||||
@ -32,7 +32,7 @@ export default class SettingAPI {
|
||||
return res?.data?.isPresent;
|
||||
}
|
||||
|
||||
private static toSettingsMap (names: Array<SettingName>, data: Record<string, string|null>): Map<SettingName, string> {
|
||||
private static toSettingsMap (names: readonly SettingName[], data: Record<string, string|null>): Map<SettingName, string> {
|
||||
const map = new Map();
|
||||
names.forEach(name => {
|
||||
map.set(name, data[name] || '');
|
||||
|
@ -1,9 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Select from 'react-select';
|
||||
import { PencilSimple } from 'phosphor-react';
|
||||
import { ArrayPath, Path, useFieldArray, UseFormRegister } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { Control, FormState, UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
import { PencilSimple, X } from 'phosphor-react';
|
||||
import { useFieldArray, UseFormRegister } from 'react-hook-form';
|
||||
import { Control, FormState, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Product, ProductStockMovement, StockMovementReason, StockType } from '../../models/product';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
@ -15,15 +14,14 @@ import { ProductStockModal } from './product-stock-modal';
|
||||
import { FabStateLabel } from '../base/fab-state-label';
|
||||
import ProductAPI from '../../api/product';
|
||||
import FormatLib from '../../lib/format';
|
||||
import { FieldPathValue } from 'react-hook-form/dist/types/path';
|
||||
import ProductLib from '../../lib/product';
|
||||
|
||||
interface ProductStockFormProps<TFieldValues, TContext extends object> {
|
||||
interface ProductStockFormProps<TContext extends object> {
|
||||
currentFormValues: Product,
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
control: Control<TFieldValues, TContext>,
|
||||
formState: FormState<TFieldValues>,
|
||||
setValue: UseFormSetValue<TFieldValues>,
|
||||
register: UseFormRegister<Product>,
|
||||
control: Control<Product, TContext>,
|
||||
formState: FormState<Product>,
|
||||
setValue: UseFormSetValue<Product>,
|
||||
onSuccess: (product: Product) => void,
|
||||
onError: (message: string) => void,
|
||||
}
|
||||
@ -33,7 +31,7 @@ const DEFAULT_LOW_STOCK_THRESHOLD = 30;
|
||||
/**
|
||||
* Form tab to manage a product's stock
|
||||
*/
|
||||
export const ProductStockForm = <TFieldValues extends FieldValues, TContext extends object> ({ currentFormValues, register, control, formState, setValue, onError }: ProductStockFormProps<TFieldValues, TContext>) => {
|
||||
export const ProductStockForm = <TContext extends object> ({ currentFormValues, register, control, formState, setValue, onError }: ProductStockFormProps<TContext>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [activeThreshold, setActiveThreshold] = useState<boolean>(currentFormValues.low_stock_threshold != null);
|
||||
@ -41,7 +39,7 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [stockMovements, setStockMovements] = useState<Array<ProductStockMovement>>([]);
|
||||
|
||||
const { fields, append } = useFieldArray({ control, name: 'product_stock_movements_attributes' as ArrayPath<TFieldValues> });
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'product_stock_movements_attributes' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFormValues?.id) return;
|
||||
@ -103,8 +101,8 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
|
||||
const toggleStockThreshold = (checked: boolean) => {
|
||||
setActiveThreshold(checked);
|
||||
setValue(
|
||||
'low_stock_threshold' as Path<TFieldValues>,
|
||||
(checked ? DEFAULT_LOW_STOCK_THRESHOLD : null) as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
'low_stock_threshold',
|
||||
(checked ? DEFAULT_LOW_STOCK_THRESHOLD : null)
|
||||
);
|
||||
};
|
||||
|
||||
@ -154,6 +152,35 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
|
||||
</div>
|
||||
<FabButton onClick={toggleModal} icon={<PencilSimple size={20} weight="fill" />} className="is-black">Modifier</FabButton>
|
||||
</div>
|
||||
|
||||
{fields.length > 0 && <div className="ongoing-stocks">
|
||||
<span className="title">{t('app.admin.store.product_stock_form.ongoing_operations')}</span>
|
||||
<span className="save-notice">{t('app.admin.store.product_stock_form.save_reminder')}</span>
|
||||
{fields.map((newMovement, index) => (
|
||||
<div key={index} className="unsaved-stock-movement stock-item">
|
||||
<div className="group">
|
||||
<p>{t(`app.admin.store.product_stock_form.type_${ProductLib.stockMovementType(newMovement.reason)}`)}</p>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>{t(`app.admin.store.product_stock_form.${newMovement.stock_type}`)}</span>
|
||||
<p>{ProductLib.absoluteStockMovement(newMovement.quantity, newMovement.reason)}</p>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.reason')}</span>
|
||||
<p>{t(ProductLib.stockMovementReasonTrKey(newMovement.reason))}</p>
|
||||
</div>
|
||||
<p className="cancel-action" onClick={() => remove(index)}>
|
||||
{t('app.admin.store.product_stock_form.cancel')}
|
||||
<X size={20} />
|
||||
</p>
|
||||
<FormInput id={`product_stock_movements_attributes.${index}.stock_type`} register={register}
|
||||
type="hidden" />
|
||||
<FormInput id={`product_stock_movements_attributes.${index}.quantity`} register={register} type="hidden" />
|
||||
<FormInput id={`product_stock_movements_attributes.${index}.reason`} register={register} type="hidden" />
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="threshold-data">
|
||||
@ -213,15 +240,15 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
|
||||
<p className='title'>{currentFormValues.name}</p>
|
||||
<p>{FormatLib.date(movement.date)}</p>
|
||||
<div className="group">
|
||||
<span>{movement.stock_type}</span>
|
||||
<p>{movement.quantity}</p>
|
||||
<span>{t(`app.admin.store.product_stock_form.${movement.stock_type}`)}</span>
|
||||
<p>{ProductLib.absoluteStockMovement(movement.quantity, movement.reason)}</p>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.event_type')}</span>
|
||||
<p>{movement.reason}</p>
|
||||
<span>{t('app.admin.store.product_stock_form.reason')}</span>
|
||||
<p>{t(ProductLib.stockMovementReasonTrKey(movement.reason))}</p>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.stock_level')}</span>
|
||||
<span>{t('app.admin.store.product_stock_form.remaining_stock')}</span>
|
||||
<p>{movement.remaining_stock}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -231,13 +258,6 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
|
||||
onSuccess={onNewStockMovement}
|
||||
isOpen={isOpen}
|
||||
toggleModal={toggleModal} />
|
||||
{fields.map((newMovement, index) => (
|
||||
<div key={index}>
|
||||
<FormInput id={`product_stock_movements_attributes.${index}.stock_type`} register={register} type="hidden" />
|
||||
<FormInput id={`product_stock_movements_attributes.${index}.quantity`} register={register} type="hidden" />
|
||||
<FormInput id={`product_stock_movements_attributes.${index}.reason`} register={register} type="hidden" />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
@ -1,6 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ProductStockMovement, StockMovementReason, StockType } from '../../models/product';
|
||||
import {
|
||||
ProductStockMovement,
|
||||
stockMovementInReasons,
|
||||
stockMovementOutReasons,
|
||||
StockMovementReason,
|
||||
StockType
|
||||
} from '../../models/product';
|
||||
import { FormSelect } from '../form/form-select';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
@ -57,12 +63,7 @@ export const ProductStockModal: React.FC<ProductStockModalProps> = ({ onError, o
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildEventsOptions = (): Array<reasonSelectOption> => {
|
||||
const options: Record<string, Array<StockMovementReason>> = {
|
||||
in: ['inward_stock', 'returned', 'cancelled', 'inventory_fix'],
|
||||
out: ['sold', 'missing', 'damaged']
|
||||
};
|
||||
|
||||
return options[movement].map(key => {
|
||||
return (movement === 'in' ? stockMovementInReasons : stockMovementOutReasons).map(key => {
|
||||
return { value: key, label: t(ProductLib.stockMovementReasonTrKey(key)) };
|
||||
});
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ProductCategory } from '../models/product-category';
|
||||
import { StockMovementReason } from '../models/product';
|
||||
import { stockMovementInReasons, stockMovementOutReasons, StockMovementReason } from '../models/product';
|
||||
|
||||
export default class ProductLib {
|
||||
/**
|
||||
@ -26,4 +26,25 @@ export default class ProductLib {
|
||||
static stockMovementReasonTrKey = (reason: StockMovementReason): string => {
|
||||
return `app.admin.store.stock_movement_reason.${reason}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given stock movement is of type 'in' or 'out'
|
||||
*/
|
||||
static stockMovementType = (reason: StockMovementReason): 'in' | 'out' => {
|
||||
if ((stockMovementInReasons as readonly StockMovementReason[]).includes(reason)) return 'in';
|
||||
if ((stockMovementOutReasons as readonly StockMovementReason[]).includes(reason)) return 'out';
|
||||
|
||||
throw new Error(`Unexpected stock movement reason: ${reason}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the given quantity, prefixed by its addition operator (- or +)
|
||||
*/
|
||||
static absoluteStockMovement = (quantity: number, reason: StockMovementReason): string => {
|
||||
if (ProductLib.stockMovementType(reason) === 'in') {
|
||||
return `+${quantity}`;
|
||||
} else {
|
||||
return `-${quantity}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -6,7 +6,12 @@ export interface ProductIndexFilter extends ApiFilter {
|
||||
}
|
||||
|
||||
export type StockType = 'internal' | 'external' | 'all';
|
||||
export type StockMovementReason = 'inward_stock' | 'returned' | 'cancelled' | 'inventory_fix' | 'sold' | 'missing' | 'damaged';
|
||||
|
||||
export const stockMovementInReasons = ['inward_stock', 'returned', 'cancelled', 'inventory_fix'] as const;
|
||||
export const stockMovementOutReasons = ['sold', 'missing', 'damaged'] as const;
|
||||
export const stockMovementAllReasons = [...stockMovementInReasons, ...stockMovementOutReasons] as const;
|
||||
|
||||
export type StockMovementReason = typeof stockMovementAllReasons[number];
|
||||
|
||||
export interface Stock {
|
||||
internal: number,
|
||||
|
@ -7,20 +7,20 @@ export const homePageSettings = [
|
||||
'home_content',
|
||||
'home_css',
|
||||
'upcoming_events_shown'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const privacyPolicySettings = [
|
||||
'privacy_draft',
|
||||
'privacy_body',
|
||||
'privacy_dpo'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const aboutPageSettings = [
|
||||
'about_title',
|
||||
'about_body',
|
||||
'about_contacts',
|
||||
'link_name'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const socialNetworksSettings = [
|
||||
'facebook',
|
||||
@ -36,7 +36,7 @@ export const socialNetworksSettings = [
|
||||
'pinterest',
|
||||
'lastfm',
|
||||
'flickr'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const messagesSettings = [
|
||||
'machine_explications_alert',
|
||||
@ -45,7 +45,7 @@ export const messagesSettings = [
|
||||
'subscription_explications_alert',
|
||||
'event_explications_alert',
|
||||
'space_explications_alert'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const invoicesSettings = [
|
||||
'invoice_logo',
|
||||
@ -64,7 +64,7 @@ export const invoicesSettings = [
|
||||
'invoice_legals',
|
||||
'invoice_prefix',
|
||||
'payment_schedule_prefix'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const bookingSettings = [
|
||||
'booking_window_start',
|
||||
@ -81,17 +81,17 @@ export const bookingSettings = [
|
||||
'book_overlapping_slots',
|
||||
'slot_duration',
|
||||
'overlapping_categories'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const themeSettings = [
|
||||
'main_color',
|
||||
'secondary_color'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const titleSettings = [
|
||||
'fablab_name',
|
||||
'name_genre'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const accountingSettings = [
|
||||
'accounting_journal_code',
|
||||
@ -115,7 +115,7 @@ export const accountingSettings = [
|
||||
'accounting_Event_label',
|
||||
'accounting_Space_code',
|
||||
'accounting_Space_label'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const modulesSettings = [
|
||||
'spaces_module',
|
||||
@ -127,13 +127,13 @@ export const modulesSettings = [
|
||||
'online_payment_module',
|
||||
'public_agenda_module',
|
||||
'invoicing_module'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const stripeSettings = [
|
||||
'stripe_public_key',
|
||||
'stripe_secret_key',
|
||||
'stripe_currency'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const payzenSettings = [
|
||||
'payzen_username',
|
||||
@ -142,13 +142,13 @@ export const payzenSettings = [
|
||||
'payzen_public_key',
|
||||
'payzen_hmac',
|
||||
'payzen_currency'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const openLabSettings = [
|
||||
'openlab_app_id',
|
||||
'openlab_app_secret',
|
||||
'openlab_default'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const accountSettings = [
|
||||
'phone_required',
|
||||
@ -157,13 +157,13 @@ export const accountSettings = [
|
||||
'user_change_group',
|
||||
'user_validation_required',
|
||||
'user_validation_required_list'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const analyticsSettings = [
|
||||
'tracking_id',
|
||||
'facebook_app_id',
|
||||
'twitter_analytics'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const fabHubSettings = [
|
||||
'hub_last_version',
|
||||
@ -171,43 +171,43 @@ export const fabHubSettings = [
|
||||
'fab_analytics',
|
||||
'origin',
|
||||
'uuid'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const projectsSettings = [
|
||||
'allowed_cad_extensions',
|
||||
'allowed_cad_mime_types',
|
||||
'disqus_shortname'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const prepaidPacksSettings = [
|
||||
'renew_pack_threshold',
|
||||
'pack_only_for_subscription'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const registrationSettings = [
|
||||
'public_registrations',
|
||||
'recaptcha_site_key',
|
||||
'recaptcha_secret_key'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const adminSettings = [
|
||||
'feature_tour_display',
|
||||
'show_username_in_admin_list'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const pricingSettings = [
|
||||
'extended_prices_in_same_day'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const poymentSettings = [
|
||||
'payment_gateway'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const displaySettings = [
|
||||
'machines_sort_by',
|
||||
'events_in_calendar',
|
||||
'email_from'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const allSettings = [
|
||||
...homePageSettings,
|
||||
|
@ -1,6 +1,31 @@
|
||||
.product-stock-form {
|
||||
h4 span { @include text-sm; }
|
||||
|
||||
.ongoing-stocks {
|
||||
margin: 2.4rem 0;
|
||||
.save-notice {
|
||||
@include text-xs;
|
||||
margin-left: 1rem;
|
||||
color: var(--alert);
|
||||
}
|
||||
.unsaved-stock-movement {
|
||||
background-color: var(--gray-soft-light);
|
||||
border: 0;
|
||||
padding: 1.2rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
.cancel-action {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
svg {
|
||||
margin-left: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.store-list {
|
||||
h4 { margin: 0; }
|
||||
}
|
||||
|
@ -1992,18 +1992,25 @@ en:
|
||||
product_stock_form:
|
||||
stock_up_to_date: "Stock up to date"
|
||||
date_time: "{DATE} - {TIME}"
|
||||
ongoing_operations: "Ongoing stocks operations"
|
||||
save_reminder: "Don't forget to save your operations"
|
||||
low_stock_threshold: "Define a low stock threshold"
|
||||
stock_threshold_toggle: "Activate stock threshold"
|
||||
stock_threshold_information: "<strong>Information</strong></br>Define a low stock threshold and receive a notification when it's reached.<br>Above the threshold, the product is available in the store. When the threshold is reached, the product quantity is labeled as low."
|
||||
low_stock: "Low stock"
|
||||
threshold_level: "Minimum threshold level"
|
||||
threshold_alert: "Notify me when the threshold is reached"
|
||||
events_history: "Events history"
|
||||
event_type: "Events:"
|
||||
reason: "Reason"
|
||||
stocks: "Stocks:"
|
||||
internal: "Private stock"
|
||||
external: "Public stock"
|
||||
all: "All types"
|
||||
stock_level: "Stock level"
|
||||
remaining_stock: "Remaining stock"
|
||||
type_in: "Add"
|
||||
type_out: "Remove"
|
||||
cancel: "Cancel this operation"
|
||||
product_stock_modal:
|
||||
modal_title: "Manage stock"
|
||||
internal: "Private stock"
|
||||
@ -2018,7 +2025,7 @@ en:
|
||||
stock_movement_reason:
|
||||
inward_stock: "Inward stock"
|
||||
returned: "Returned by client"
|
||||
canceled: "Canceled by client"
|
||||
cancelled: "Canceled by client"
|
||||
inventory_fix: "Inventory fix"
|
||||
sold: "Sold"
|
||||
missing: "Missing in stock"
|
||||
|
18
yarn.lock
18
yarn.lock
@ -3299,20 +3299,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001313:
|
||||
version "1.0.30001314"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001314.tgz#65c7f9fb7e4594fca0a333bec1d8939662377596"
|
||||
integrity sha512-0zaSO+TnCHtHJIbpLroX7nsD+vYuOVjl3uzFbJO1wMVbuveJA0RK2WcQA9ZUIOiO0/ArMiMgHJLxfEZhQiC0kw==
|
||||
|
||||
caniuse-lite@^1.0.30001219:
|
||||
version "1.0.30001296"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001296.tgz"
|
||||
integrity sha512-WfrtPEoNSoeATDlf4y3QvkwiELl9GyPLISV5GejTbbQRtQx4LhsXmc9IQ6XCL2d7UxCyEzToEZNMeqR79OUw8Q==
|
||||
|
||||
caniuse-lite@^1.0.30001332:
|
||||
version "1.0.30001335"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001335.tgz#899254a0b70579e5a957c32dced79f0727c61f2a"
|
||||
integrity sha512-ddP1Tgm7z2iIxu6QTtbZUv6HJxSaV/PZeSrWFZtbY4JZ69tOeNhBCl3HyRQgeNZKE5AOn1kpV7fhljigy0Ty3w==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001313, caniuse-lite@^1.0.30001332:
|
||||
version "1.0.30001397"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001397.tgz"
|
||||
integrity sha512-SW9N2TbCdLf0eiNDRrrQXx2sOkaakNZbCjgNpPyMJJbiOrU5QzMIrXOVMRM1myBXTD5iTkdrtU/EguCrBocHlA==
|
||||
|
||||
chalk@^2.0.0, chalk@^2.4.2:
|
||||
version "2.4.2"
|
||||
|
Loading…
x
Reference in New Issue
Block a user