mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2024-12-01 12:24:28 +01:00
(merge) branch 'origin/product-store'
This commit is contained in:
commit
ffafb223f1
37
app/controllers/api/orders_controller.rb
Normal file
37
app/controllers/api/orders_controller.rb
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# API Controller for resources of type Order
|
||||||
|
# Orders are used in store
|
||||||
|
class API::OrdersController < API::ApiController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :set_order, only: %i[show update destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@result = ::Orders::OrderService.list(params, current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show; end
|
||||||
|
|
||||||
|
def update
|
||||||
|
authorize @order
|
||||||
|
|
||||||
|
@order = ::Orders::OrderService.update_state(@order, current_user, order_params[:state], order_params[:note])
|
||||||
|
render :show
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
authorize @order
|
||||||
|
@order.destroy
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_order
|
||||||
|
@order = Order.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def order_params
|
||||||
|
params.require(:order).permit(:state, :note)
|
||||||
|
end
|
||||||
|
end
|
@ -6,6 +6,8 @@ class API::ProductsController < API::ApiController
|
|||||||
before_action :authenticate_user!, except: %i[index show]
|
before_action :authenticate_user!, except: %i[index show]
|
||||||
before_action :set_product, only: %i[update destroy]
|
before_action :set_product, only: %i[update destroy]
|
||||||
|
|
||||||
|
MOVEMENTS_PER_PAGE = 10
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@products = ProductService.list(params)
|
@products = ProductService.list(params)
|
||||||
@pages = ProductService.pages(params) if params[:page].present?
|
@pages = ProductService.pages(params) if params[:page].present?
|
||||||
@ -17,8 +19,7 @@ class API::ProductsController < API::ApiController
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
authorize Product
|
authorize Product
|
||||||
@product = Product.new(product_params)
|
@product = ProductService.create(product_params, params[:product][:product_stock_movements_attributes])
|
||||||
@product.amount = ProductService.amount_multiplied_by_hundred(@product.amount)
|
|
||||||
if @product.save
|
if @product.save
|
||||||
render status: :created
|
render status: :created
|
||||||
else
|
else
|
||||||
@ -29,9 +30,8 @@ class API::ProductsController < API::ApiController
|
|||||||
def update
|
def update
|
||||||
authorize @product
|
authorize @product
|
||||||
|
|
||||||
product_parameters = product_params
|
@product = ProductService.update(@product, product_params, params[:product][:product_stock_movements_attributes])
|
||||||
product_parameters[:amount] = ProductService.amount_multiplied_by_hundred(product_parameters[:amount])
|
if @product.save
|
||||||
if @product.update(product_parameters)
|
|
||||||
render status: :ok
|
render status: :ok
|
||||||
else
|
else
|
||||||
render json: @product.errors.full_messages, status: :unprocessable_entity
|
render json: @product.errors.full_messages, status: :unprocessable_entity
|
||||||
@ -44,6 +44,11 @@ class API::ProductsController < API::ApiController
|
|||||||
head :no_content
|
head :no_content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def stock_movements
|
||||||
|
authorize Product
|
||||||
|
@movements = ProductStockMovement.where(product_id: params[:id]).order(date: :desc).page(params[:page]).per(MOVEMENTS_PER_PAGE)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_product
|
def set_product
|
||||||
@ -56,7 +61,6 @@ class API::ProductsController < API::ApiController
|
|||||||
:low_stock_alert, :low_stock_threshold,
|
:low_stock_alert, :low_stock_threshold,
|
||||||
machine_ids: [],
|
machine_ids: [],
|
||||||
product_files_attributes: %i[id attachment _destroy],
|
product_files_attributes: %i[id attachment _destroy],
|
||||||
product_images_attributes: %i[id attachment is_main _destroy],
|
product_images_attributes: %i[id attachment is_main _destroy])
|
||||||
product_stock_movements_attributes: %i[id quantity reason stock_type _destroy])
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
3
app/exceptions/update_order_state_error.rb
Normal file
3
app/exceptions/update_order_state_error.rb
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Raised when update order state error
|
||||||
|
class UpdateOrderStateError < StandardError
|
||||||
|
end
|
21
app/frontend/src/javascript/api/order.ts
Normal file
21
app/frontend/src/javascript/api/order.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import apiClient from './clients/api-client';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { Order, OrderIndexFilter, OrderIndex } from '../models/order';
|
||||||
|
import ApiLib from '../lib/api';
|
||||||
|
|
||||||
|
export default class OrderAPI {
|
||||||
|
static async index (filters?: OrderIndexFilter): Promise<OrderIndex> {
|
||||||
|
const res: AxiosResponse<OrderIndex> = await apiClient.get(`/api/orders${ApiLib.filtersToQuery(filters)}`);
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async get (id: number | string): Promise<Order> {
|
||||||
|
const res: AxiosResponse<Order> = await apiClient.get(`/api/orders/${id}`);
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateState (order: Order, state: string, note?: string): Promise<Order> {
|
||||||
|
const res: AxiosResponse<Order> = await apiClient.patch(`/api/orders/${order.id}`, { order: { state, note } });
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import apiClient from './clients/api-client';
|
import apiClient from './clients/api-client';
|
||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import { serialize } from 'object-to-formdata';
|
import { serialize } from 'object-to-formdata';
|
||||||
import { Product, ProductIndexFilter, ProductsIndex } from '../models/product';
|
import { Product, ProductIndexFilter, ProductsIndex, ProductStockMovement } from '../models/product';
|
||||||
import ApiLib from '../lib/api';
|
import ApiLib from '../lib/api';
|
||||||
|
|
||||||
export default class ProductAPI {
|
export default class ProductAPI {
|
||||||
@ -89,4 +89,9 @@ export default class ProductAPI {
|
|||||||
const res: AxiosResponse<void> = await apiClient.delete(`/api/products/${productId}`);
|
const res: AxiosResponse<void> = await apiClient.delete(`/api/products/${productId}`);
|
||||||
return res?.data;
|
return res?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async stockMovements (productId: number, page = 1): Promise<Array<ProductStockMovement>> {
|
||||||
|
const res: AxiosResponse<Array<ProductStockMovement>> = await apiClient.get(`/api/products/${productId}/stock_movements?page=${page}`);
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,7 @@ export default class SettingAPI {
|
|||||||
return res?.data?.setting;
|
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();
|
const params = new URLSearchParams();
|
||||||
params.append('names', `['${names.join("','")}']`);
|
params.append('names', `['${names.join("','")}']`);
|
||||||
|
|
||||||
@ -32,7 +32,7 @@ export default class SettingAPI {
|
|||||||
return res?.data?.isPresent;
|
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();
|
const map = new Map();
|
||||||
names.forEach(name => {
|
names.forEach(name => {
|
||||||
map.set(name, data[name] || '');
|
map.set(name, data[name] || '');
|
||||||
|
@ -23,6 +23,7 @@ interface FabModalProps {
|
|||||||
customHeader?: ReactNode,
|
customHeader?: ReactNode,
|
||||||
customFooter?: ReactNode,
|
customFooter?: ReactNode,
|
||||||
onConfirm?: (event: BaseSyntheticEvent) => void,
|
onConfirm?: (event: BaseSyntheticEvent) => void,
|
||||||
|
onClose?: (event: BaseSyntheticEvent) => void,
|
||||||
preventConfirm?: boolean,
|
preventConfirm?: boolean,
|
||||||
onCreation?: () => void,
|
onCreation?: () => void,
|
||||||
onConfirmSendFormId?: string,
|
onConfirmSendFormId?: string,
|
||||||
@ -31,7 +32,7 @@ interface FabModalProps {
|
|||||||
/**
|
/**
|
||||||
* This component is a template for a modal dialog that wraps the application style
|
* This component is a template for a modal dialog that wraps the application style
|
||||||
*/
|
*/
|
||||||
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation, onConfirmSendFormId }) => {
|
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, onClose, preventConfirm, onCreation, onConfirmSendFormId }) => {
|
||||||
const { t } = useTranslation('shared');
|
const { t } = useTranslation('shared');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -40,12 +41,20 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the user request to close the modal without confirming.
|
||||||
|
*/
|
||||||
|
const handleClose = (event) => {
|
||||||
|
if (typeof onClose === 'function') onClose(event);
|
||||||
|
toggleModal();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen}
|
<Modal isOpen={isOpen}
|
||||||
className={`fab-modal fab-modal-${width} ${className || ''}`}
|
className={`fab-modal fab-modal-${width} ${className || ''}`}
|
||||||
overlayClassName="fab-modal-overlay"
|
overlayClassName="fab-modal-overlay"
|
||||||
onRequestClose={toggleModal}>
|
onRequestClose={handleClose}>
|
||||||
{closeButton && <FabButton className="modal-btn--close" onClick={toggleModal}>{t('app.shared.fab_modal.close')}</FabButton>}
|
{closeButton && <FabButton className="modal-btn--close" onClick={handleClose}>{t('app.shared.fab_modal.close')}</FabButton>}
|
||||||
<div className="fab-modal-header">
|
<div className="fab-modal-header">
|
||||||
{!customHeader && <h1>{ title }</h1>}
|
{!customHeader && <h1>{ title }</h1>}
|
||||||
{customHeader && customHeader}
|
{customHeader && customHeader}
|
||||||
|
@ -14,9 +14,9 @@ import { Order } from '../../models/order';
|
|||||||
import { MemberSelect } from '../user/member-select';
|
import { MemberSelect } from '../user/member-select';
|
||||||
import { CouponInput } from '../coupon/coupon-input';
|
import { CouponInput } from '../coupon/coupon-input';
|
||||||
import { Coupon } from '../../models/coupon';
|
import { Coupon } from '../../models/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';
|
import Switch from 'react-switch';
|
||||||
|
import OrderLib from '../../lib/order';
|
||||||
|
|
||||||
declare const Application: IApplication;
|
declare const Application: IApplication;
|
||||||
|
|
||||||
@ -82,7 +82,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
|||||||
* Handle payment
|
* Handle payment
|
||||||
*/
|
*/
|
||||||
const handlePaymentSuccess = (data: Order): void => {
|
const handlePaymentSuccess = (data: Order): void => {
|
||||||
if (data.payment_state === 'paid') {
|
if (data.state === 'paid') {
|
||||||
setPaymentModal(false);
|
setPaymentModal(false);
|
||||||
window.location.href = '/#!/store';
|
window.location.href = '/#!/store';
|
||||||
onSuccess(t('app.public.store_cart.checkout_success'));
|
onSuccess(t('app.public.store_cart.checkout_success'));
|
||||||
@ -94,8 +94,8 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
|||||||
/**
|
/**
|
||||||
* Change cart's customer by admin/manger
|
* Change cart's customer by admin/manger
|
||||||
*/
|
*/
|
||||||
const handleChangeMember = (userId: number): void => {
|
const handleChangeMember = (user: User): void => {
|
||||||
setCart({ ...cart, user: { id: userId, role: 'member' } });
|
setCart({ ...cart, user: { id: user.id, role: 'member' } });
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -132,52 +132,6 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the item total
|
|
||||||
*/
|
|
||||||
const itemAmount = (item): number => {
|
|
||||||
return item.quantity * Math.trunc(item.amount * 100) / 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* return true if cart has offered item
|
|
||||||
*/
|
|
||||||
const hasOfferedItem = (): boolean => {
|
|
||||||
return cart.order_items_attributes
|
|
||||||
.filter(i => i.is_offered).length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the offered item total
|
|
||||||
*/
|
|
||||||
const offeredAmount = (): number => {
|
|
||||||
return cart.order_items_attributes
|
|
||||||
.filter(i => i.is_offered)
|
|
||||||
.map(i => Math.trunc(i.amount * 100) * i.quantity)
|
|
||||||
.reduce((acc, curr) => acc + curr, 0) / 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the total amount before offered amount
|
|
||||||
*/
|
|
||||||
const totalBeforeOfferedAmount = (): number => {
|
|
||||||
return (Math.trunc(cart.total * 100) + Math.trunc(offeredAmount() * 100)) / 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the coupon amount
|
|
||||||
*/
|
|
||||||
const couponAmount = (): number => {
|
|
||||||
return (Math.trunc(cart.total * 100) - Math.trunc(computePriceWithCoupon(cart.total, cart.coupon) * 100)) / 100.00;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the paid total amount
|
|
||||||
*/
|
|
||||||
const paidTotal = (): number => {
|
|
||||||
return computePriceWithCoupon(cart.total, cart.coupon);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='store-cart'>
|
<div className='store-cart'>
|
||||||
<div className="store-cart-list">
|
<div className="store-cart-list">
|
||||||
@ -185,10 +139,10 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
|||||||
{cart && cart.order_items_attributes.map(item => (
|
{cart && cart.order_items_attributes.map(item => (
|
||||||
<article key={item.id} className='store-cart-list-item'>
|
<article key={item.id} className='store-cart-list-item'>
|
||||||
<div className='picture'>
|
<div className='picture'>
|
||||||
<img alt=''src={noImage} />
|
<img alt=''src={item.orderable_main_image_url || noImage} />
|
||||||
</div>
|
</div>
|
||||||
<div className="ref">
|
<div className="ref">
|
||||||
<span>{t('app.public.store_cart.reference_short')} </span>
|
<span>{t('app.public.store_cart.reference_short')} {item.orderable_ref || ''}</span>
|
||||||
<p>{item.orderable_name}</p>
|
<p>{item.orderable_name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
@ -197,13 +151,13 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
|||||||
<span>/ {t('app.public.store_cart.unit')}</span>
|
<span>/ {t('app.public.store_cart.unit')}</span>
|
||||||
</div>
|
</div>
|
||||||
<select value={item.quantity} onChange={changeProductQuantity(item)}>
|
<select value={item.quantity} onChange={changeProductQuantity(item)}>
|
||||||
{Array.from({ length: 100 }, (_, i) => i + 1).map(v => (
|
{Array.from({ length: 100 }, (_, i) => i + item.quantity_min).map(v => (
|
||||||
<option key={v} value={v}>{v}</option>
|
<option key={v} value={v}>{v}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<div className='total'>
|
<div className='total'>
|
||||||
<span>{t('app.public.store_cart.total')}</span>
|
<span>{t('app.public.store_cart.total')}</span>
|
||||||
<p>{FormatLib.price(itemAmount(item))}</p>
|
<p>{FormatLib.price(OrderLib.itemAmount(item))}</p>
|
||||||
</div>
|
</div>
|
||||||
<FabButton className="main-action-btn" onClick={removeProductFromCart(item)}>
|
<FabButton className="main-action-btn" onClick={removeProductFromCart(item)}>
|
||||||
<i className="fa fa-trash" />
|
<i className="fa fa-trash" />
|
||||||
@ -251,15 +205,15 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
|||||||
<h3>{t('app.public.store_cart.checkout_header')}</h3>
|
<h3>{t('app.public.store_cart.checkout_header')}</h3>
|
||||||
<span>{t('app.public.store_cart.checkout_products_COUNT', { COUNT: cart?.order_items_attributes.length })}</span>
|
<span>{t('app.public.store_cart.checkout_products_COUNT', { COUNT: cart?.order_items_attributes.length })}</span>
|
||||||
<div className="list">
|
<div className="list">
|
||||||
<p>{t('app.public.store_cart.checkout_products_total')} <span>{FormatLib.price(totalBeforeOfferedAmount())}</span></p>
|
<p>{t('app.public.store_cart.checkout_products_total')} <span>{FormatLib.price(OrderLib.totalBeforeOfferedAmount(cart))}</span></p>
|
||||||
{hasOfferedItem() &&
|
{OrderLib.hasOfferedItem(cart) &&
|
||||||
<p className='gift'>{t('app.public.store_cart.checkout_gift_total')} <span>-{FormatLib.price(offeredAmount())}</span></p>
|
<p className='gift'>{t('app.public.store_cart.checkout_gift_total')} <span>-{FormatLib.price(OrderLib.offeredAmount(cart))}</span></p>
|
||||||
}
|
}
|
||||||
{cart.coupon &&
|
{cart.coupon &&
|
||||||
<p>{t('app.public.store_cart.checkout_coupon')} <span>-{FormatLib.price(couponAmount())}</span></p>
|
<p>{t('app.public.store_cart.checkout_coupon')} <span>-{FormatLib.price(OrderLib.couponAmount(cart))}</span></p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p className='total'>{t('app.public.store_cart.checkout_total')} <span>{FormatLib.price(paidTotal())}</span></p>
|
<p className='total'>{t('app.public.store_cart.checkout_total')} <span>{FormatLib.price(OrderLib.paidTotal(cart))}</span></p>
|
||||||
</div>
|
</div>
|
||||||
<FabButton className='checkout-btn' onClick={checkout} disabled={!cart.user}>
|
<FabButton className='checkout-btn' onClick={checkout} disabled={!cart.user}>
|
||||||
{t('app.public.store_cart.checkout')}
|
{t('app.public.store_cart.checkout')}
|
||||||
|
@ -18,12 +18,13 @@ interface FormInputProps<TFieldValues, TInputType> extends FormComponent<TFieldV
|
|||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
step?: number | 'any',
|
step?: number | 'any',
|
||||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
|
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
|
||||||
|
nullable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component is a template for an input component to use within React Hook Form
|
* This component is a template for an input component to use within React Hook Form
|
||||||
*/
|
*/
|
||||||
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept }: FormInputProps<TFieldValues, TInputType>) => {
|
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false }: FormInputProps<TFieldValues, TInputType>) => {
|
||||||
/**
|
/**
|
||||||
* Debounced (ie. temporised) version of the 'on change' callback.
|
* Debounced (ie. temporised) version of the 'on change' callback.
|
||||||
*/
|
*/
|
||||||
@ -57,8 +58,8 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
|
|||||||
<input id={id}
|
<input id={id}
|
||||||
{...register(id as FieldPath<TFieldValues>, {
|
{...register(id as FieldPath<TFieldValues>, {
|
||||||
...rules,
|
...rules,
|
||||||
valueAsNumber: type === 'number',
|
|
||||||
valueAsDate: type === 'date',
|
valueAsDate: type === 'date',
|
||||||
|
setValueAs: v => (v === null && nullable) ? null : (type === 'number' ? parseInt(v, 10) : v),
|
||||||
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
|
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
|
||||||
onChange: (e) => { handleChange(e); }
|
onChange: (e) => { handleChange(e); }
|
||||||
})}
|
})}
|
||||||
|
@ -0,0 +1,112 @@
|
|||||||
|
import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { UIRouter } from '@uirouter/angularjs';
|
||||||
|
import { FormState } from 'react-hook-form/dist/types/form';
|
||||||
|
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||||
|
import { FabModal } from '../base/fab-modal';
|
||||||
|
import Deferred from '../../lib/deferred';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface UnsavedFormAlertProps<TFieldValues> {
|
||||||
|
uiRouter: UIRouter,
|
||||||
|
formState: FormState<TFieldValues>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alert the user about unsaved changes in the given form, before leaving the current page.
|
||||||
|
* This component is highly dependent of these external libraries:
|
||||||
|
* - [react-hook-form](https://react-hook-form.com/)
|
||||||
|
* - [ui-router](https://ui-router.github.io/)
|
||||||
|
*/
|
||||||
|
export const UnsavedFormAlert = <TFieldValues extends FieldValues>({ uiRouter, formState, children }: PropsWithChildren<UnsavedFormAlertProps<TFieldValues>>) => {
|
||||||
|
const { t } = useTranslation('shared');
|
||||||
|
|
||||||
|
const [showAlertModal, setShowAlertModal] = useState<boolean>(false);
|
||||||
|
const [promise, setPromise] = useState<Deferred<boolean>>(null);
|
||||||
|
const [dirty, setDirty] = useState<boolean>(formState.isDirty);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const submitStatus = (!formState.isSubmitting && (!formState.isSubmitted || !formState.isSubmitSuccessful));
|
||||||
|
setDirty(submitStatus && Object.keys(formState.dirtyFields).length > 0);
|
||||||
|
}, [formState]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current form is dirty. If so, show the confirmation modal and return a promise
|
||||||
|
*/
|
||||||
|
const alertOnDirtyForm = (isDirty: boolean): Promise<boolean>|void => {
|
||||||
|
if (isDirty) {
|
||||||
|
toggleAlertModal();
|
||||||
|
const userChoicePromise = new Deferred<boolean>();
|
||||||
|
setPromise(userChoicePromise);
|
||||||
|
return userChoicePromise.promise;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// memoised version of the alertOnDirtyForm function, will be updated only when the form becames dirty
|
||||||
|
const alertDirty = useCallback<() => Promise<boolean>|void>(() => alertOnDirtyForm(dirty), [dirty]);
|
||||||
|
|
||||||
|
// we should place this useEffect after the useCallback declaration (because it's a scoped variable)
|
||||||
|
useEffect(() => {
|
||||||
|
const { transitionService, globals: { current } } = uiRouter;
|
||||||
|
const deregisters = transitionService.onBefore({ from: current.name }, alertDirty);
|
||||||
|
return () => {
|
||||||
|
deregisters();
|
||||||
|
};
|
||||||
|
}, [alertDirty]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the user tries to close the current page (tab/window), we alert him about unsaved changes
|
||||||
|
*/
|
||||||
|
const alertOnExit = (event: BeforeUnloadEvent, isDirty: boolean) => {
|
||||||
|
if (isDirty) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// memoised version of the alertOnExit function, will be updated only when the form becames dirty
|
||||||
|
const alertExit = useCallback<(event: BeforeUnloadEvent) => void>((event) => alertOnExit(event, dirty), [dirty]);
|
||||||
|
|
||||||
|
// we should place this useEffect after the useCallback declaration (because it's a scoped variable)
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('beforeunload', alertExit);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', alertExit);
|
||||||
|
};
|
||||||
|
}, [alertExit]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide/show the alert modal "you have some unsaved content, are you sure you want to leave?"
|
||||||
|
*/
|
||||||
|
const toggleAlertModal = () => {
|
||||||
|
setShowAlertModal(!showAlertModal);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the user has choosen: continue and exit
|
||||||
|
*/
|
||||||
|
const handleConfirmation = () => {
|
||||||
|
promise.resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the user has choosen: cancel and stay
|
||||||
|
*/
|
||||||
|
const handleCancel = () => {
|
||||||
|
promise.resolve(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="unsaved-form-alert">
|
||||||
|
{children}
|
||||||
|
<FabModal isOpen={showAlertModal}
|
||||||
|
toggleModal={toggleAlertModal}
|
||||||
|
confirmButton={t('app.shared.unsaved_form_alert.confirmation_button')}
|
||||||
|
title={t('app.shared.unsaved_form_alert.modal_title')}
|
||||||
|
onConfirm={handleConfirmation}
|
||||||
|
onClose={handleCancel}
|
||||||
|
closeButton>
|
||||||
|
{t('app.shared.unsaved_form_alert.confirmation_message')}
|
||||||
|
</FabModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -55,7 +55,7 @@ const ProductCategories: React.FC<ProductCategoriesProps> = ({ onSuccess, onErro
|
|||||||
*/
|
*/
|
||||||
const refreshCategories = () => {
|
const refreshCategories = () => {
|
||||||
ProductCategoryAPI.index().then(data => {
|
ProductCategoryAPI.index().then(data => {
|
||||||
setProductCategories(new ProductLib().sortCategories(data));
|
setProductCategories(ProductLib.sortCategories(data));
|
||||||
}).catch((error) => onError(error));
|
}).catch((error) => onError(error));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ import { IApplication } from '../../models/application';
|
|||||||
import { ProductForm } from './product-form';
|
import { ProductForm } from './product-form';
|
||||||
import { Product } from '../../models/product';
|
import { Product } from '../../models/product';
|
||||||
import ProductAPI from '../../api/product';
|
import ProductAPI from '../../api/product';
|
||||||
|
import { UIRouter } from '@uirouter/angularjs';
|
||||||
|
|
||||||
declare const Application: IApplication;
|
declare const Application: IApplication;
|
||||||
|
|
||||||
@ -13,12 +14,13 @@ interface EditProductProps {
|
|||||||
productId: number,
|
productId: number,
|
||||||
onSuccess: (message: string) => void,
|
onSuccess: (message: string) => void,
|
||||||
onError: (message: string) => void,
|
onError: (message: string) => void,
|
||||||
|
uiRouter: UIRouter
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component show edit product form
|
* This component show edit product form
|
||||||
*/
|
*/
|
||||||
const EditProduct: React.FC<EditProductProps> = ({ productId, onSuccess, onError }) => {
|
const EditProduct: React.FC<EditProductProps> = ({ productId, onSuccess, onError, uiRouter }) => {
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
const [product, setProduct] = useState<Product>();
|
const [product, setProduct] = useState<Product>();
|
||||||
@ -40,19 +42,23 @@ const EditProduct: React.FC<EditProductProps> = ({ productId, onSuccess, onError
|
|||||||
if (product) {
|
if (product) {
|
||||||
return (
|
return (
|
||||||
<div className="edit-product">
|
<div className="edit-product">
|
||||||
<ProductForm product={product} title={product.name} onSuccess={saveProductSuccess} onError={onError} />
|
<ProductForm product={product}
|
||||||
|
title={product.name}
|
||||||
|
onSuccess={saveProductSuccess}
|
||||||
|
onError={onError}
|
||||||
|
uiRouter={uiRouter} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditProductWrapper: React.FC<EditProductProps> = ({ productId, onSuccess, onError }) => {
|
const EditProductWrapper: React.FC<EditProductProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Loader>
|
<Loader>
|
||||||
<EditProduct productId={productId} onSuccess={onSuccess} onError={onError} />
|
<EditProduct {...props} />
|
||||||
</Loader>
|
</Loader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError']));
|
Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError', 'uiRouter']));
|
||||||
|
@ -4,18 +4,20 @@ 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 { ProductForm } from './product-form';
|
import { ProductForm } from './product-form';
|
||||||
|
import { UIRouter } from '@uirouter/angularjs';
|
||||||
|
|
||||||
declare const Application: IApplication;
|
declare const Application: IApplication;
|
||||||
|
|
||||||
interface NewProductProps {
|
interface NewProductProps {
|
||||||
onSuccess: (message: string) => void,
|
onSuccess: (message: string) => void,
|
||||||
onError: (message: string) => void,
|
onError: (message: string) => void,
|
||||||
|
uiRouter: UIRouter,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component show new product form
|
* This component show new product form
|
||||||
*/
|
*/
|
||||||
const NewProduct: React.FC<NewProductProps> = ({ onSuccess, onError }) => {
|
const NewProduct: React.FC<NewProductProps> = ({ onSuccess, onError, uiRouter }) => {
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
const product = {
|
const product = {
|
||||||
@ -46,17 +48,21 @@ const NewProduct: React.FC<NewProductProps> = ({ onSuccess, onError }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="new-product">
|
<div className="new-product">
|
||||||
<ProductForm product={product} title={t('app.admin.store.new_product.add_a_new_product')} onSuccess={saveProductSuccess} onError={onError} />
|
<ProductForm product={product}
|
||||||
|
title={t('app.admin.store.new_product.add_a_new_product')}
|
||||||
|
onSuccess={saveProductSuccess}
|
||||||
|
onError={onError}
|
||||||
|
uiRouter={uiRouter} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const NewProductWrapper: React.FC<NewProductProps> = ({ onSuccess, onError }) => {
|
const NewProductWrapper: React.FC<NewProductProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Loader>
|
<Loader>
|
||||||
<NewProduct onSuccess={onSuccess} onError={onError} />
|
<NewProduct {...props} />
|
||||||
</Loader>
|
</Loader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError']));
|
Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError', 'uiRouter']));
|
||||||
|
129
app/frontend/src/javascript/components/store/order-actions.tsx
Normal file
129
app/frontend/src/javascript/components/store/order-actions.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import React, { useState, BaseSyntheticEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import Select from 'react-select';
|
||||||
|
import { FabModal } from '../base/fab-modal';
|
||||||
|
import OrderAPI from '../../api/order';
|
||||||
|
import { Order } from '../../models/order';
|
||||||
|
|
||||||
|
interface OrderActionsProps {
|
||||||
|
order: Order,
|
||||||
|
onSuccess: (order: Order, message: string) => void,
|
||||||
|
onError: (message: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option format, expected by react-select
|
||||||
|
* @see https://github.com/JedWatson/react-select
|
||||||
|
*/
|
||||||
|
type selectOption = { value: string, label: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions for an order
|
||||||
|
*/
|
||||||
|
export const OrderActions: React.FC<OrderActionsProps> = ({ order, onSuccess, onError }) => {
|
||||||
|
const { t } = useTranslation('shared');
|
||||||
|
const [currentAction, setCurrentAction] = useState<selectOption>();
|
||||||
|
const [modalIsOpen, setModalIsOpen] = useState<boolean>(false);
|
||||||
|
const [readyNote, setReadyNote] = useState<string>('');
|
||||||
|
|
||||||
|
// Styles the React-select component
|
||||||
|
const customStyles = {
|
||||||
|
control: base => ({
|
||||||
|
...base,
|
||||||
|
width: '20ch',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}),
|
||||||
|
indicatorSeparator: () => ({
|
||||||
|
display: 'none'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the action confirmation modal
|
||||||
|
*/
|
||||||
|
const closeModal = (): void => {
|
||||||
|
setModalIsOpen(false);
|
||||||
|
setCurrentAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates sorting options to the react-select format
|
||||||
|
*/
|
||||||
|
const buildOptions = (): Array<selectOption> => {
|
||||||
|
let actions = [];
|
||||||
|
switch (order.state) {
|
||||||
|
case 'paid':
|
||||||
|
actions = actions.concat(['in_progress', 'ready', 'canceled', 'refunded']);
|
||||||
|
break;
|
||||||
|
case 'payment_failed':
|
||||||
|
actions = actions.concat(['canceled']);
|
||||||
|
break;
|
||||||
|
case 'in_progress':
|
||||||
|
actions = actions.concat(['ready', 'canceled', 'refunded']);
|
||||||
|
break;
|
||||||
|
case 'ready':
|
||||||
|
actions = actions.concat(['canceled', 'refunded']);
|
||||||
|
break;
|
||||||
|
case 'canceled':
|
||||||
|
actions = actions.concat(['refunded']);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
actions = [];
|
||||||
|
}
|
||||||
|
return actions.map(action => {
|
||||||
|
return { value: action, label: t(`app.shared.store.order_actions.state.${action}`) };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback after selecting an action
|
||||||
|
*/
|
||||||
|
const handleAction = (action: selectOption) => {
|
||||||
|
setCurrentAction(action);
|
||||||
|
setModalIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback after confirm an action
|
||||||
|
*/
|
||||||
|
const handleActionConfirmation = () => {
|
||||||
|
OrderAPI.updateState(order, currentAction.value, readyNote).then(data => {
|
||||||
|
onSuccess(data, t(`app.shared.store.order_actions.order_${currentAction.value}_success`));
|
||||||
|
setCurrentAction(null);
|
||||||
|
setModalIsOpen(false);
|
||||||
|
}).catch((e) => {
|
||||||
|
onError(e);
|
||||||
|
setCurrentAction(null);
|
||||||
|
setModalIsOpen(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
options={buildOptions()}
|
||||||
|
onChange={option => handleAction(option)}
|
||||||
|
value={currentAction}
|
||||||
|
styles={customStyles}
|
||||||
|
/>
|
||||||
|
<FabModal title={t('app.shared.store.order_actions.confirmation_required')}
|
||||||
|
isOpen={modalIsOpen}
|
||||||
|
toggleModal={closeModal}
|
||||||
|
closeButton={true}
|
||||||
|
confirmButton={t('app.shared.store.order_actions.confirm')}
|
||||||
|
onConfirm={handleActionConfirmation}
|
||||||
|
className="order-actions-confirmation-modal">
|
||||||
|
<p>{t(`app.shared.store.order_actions.confirm_order_${currentAction?.value}`)}</p>
|
||||||
|
{currentAction?.value === 'ready' &&
|
||||||
|
<textarea
|
||||||
|
id="order-ready-note"
|
||||||
|
value={readyNote}
|
||||||
|
placeholder={t('app.shared.store.order_actions.order_ready_note')}
|
||||||
|
onChange={(e: BaseSyntheticEvent) => setReadyNote(e.target.value)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
rows={5} />
|
||||||
|
}
|
||||||
|
</FabModal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -5,6 +5,7 @@ import FormatLib from '../../lib/format';
|
|||||||
import { FabButton } from '../base/fab-button';
|
import { FabButton } from '../base/fab-button';
|
||||||
import { User } from '../../models/user';
|
import { User } from '../../models/user';
|
||||||
import { FabStateLabel } from '../base/fab-state-label';
|
import { FabStateLabel } from '../base/fab-state-label';
|
||||||
|
import OrderLib from '../../lib/order';
|
||||||
|
|
||||||
interface OrderItemProps {
|
interface OrderItemProps {
|
||||||
order?: Order,
|
order?: Order,
|
||||||
@ -19,10 +20,10 @@ export const OrderItem: React.FC<OrderItemProps> = ({ order, currentUser }) => {
|
|||||||
/**
|
/**
|
||||||
* Go to order page
|
* Go to order page
|
||||||
*/
|
*/
|
||||||
const showOrder = (ref: string) => {
|
const showOrder = (order: Order) => {
|
||||||
isPrivileged()
|
isPrivileged()
|
||||||
? window.location.href = `/#!/admin/store/o/${ref}`
|
? window.location.href = `/#!/admin/store/orders/${order.id}`
|
||||||
: window.location.href = `/#!/store/o/${ref}`;
|
: window.location.href = `/#!/dashboard/orders/${order.id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,42 +33,26 @@ export const OrderItem: React.FC<OrderItemProps> = ({ order, currentUser }) => {
|
|||||||
return (currentUser?.role === 'admin' || currentUser?.role === 'manager');
|
return (currentUser?.role === 'admin' || currentUser?.role === 'manager');
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a className according to the status
|
|
||||||
*/
|
|
||||||
const statusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'error':
|
|
||||||
return 'error';
|
|
||||||
case 'canceled':
|
|
||||||
return 'canceled';
|
|
||||||
case 'pending' || 'under_preparation':
|
|
||||||
return 'pending';
|
|
||||||
default:
|
|
||||||
return 'normal';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='order-item'>
|
<div className='order-item'>
|
||||||
<p className="ref">order.ref</p>
|
<p className="ref">{order.reference}</p>
|
||||||
<div>
|
<div>
|
||||||
<FabStateLabel status={statusColor('pending')} background>
|
<FabStateLabel status={OrderLib.statusColor(order)} background>
|
||||||
order.state
|
{t(`app.shared.store.order_item.state.${OrderLib.statusText(order)}`)}
|
||||||
</FabStateLabel>
|
</FabStateLabel>
|
||||||
</div>
|
</div>
|
||||||
{isPrivileged() &&
|
{isPrivileged() &&
|
||||||
<div className='client'>
|
<div className='client'>
|
||||||
<span>{t('app.shared.store.order_item.client')}</span>
|
<span>{t('app.shared.store.order_item.client')}</span>
|
||||||
<p>order.user.name</p>
|
<p>{order?.user?.name || ''}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<p className="date">order.created_at</p>
|
<p className="date">{FormatLib.date(order.created_at)}</p>
|
||||||
<div className='price'>
|
<div className='price'>
|
||||||
<span>{t('app.shared.store.order_item.total')}</span>
|
<span>{t('app.shared.store.order_item.total')}</span>
|
||||||
<p>{FormatLib.price(order?.total)}</p>
|
<p>{FormatLib.price(order.state === 'cart' ? order.total : order.paid_total)}</p>
|
||||||
</div>
|
</div>
|
||||||
<FabButton onClick={() => showOrder('orderRef')} icon={<i className="fas fa-eye" />} className="is-black" />
|
<FabButton onClick={() => showOrder(order)} icon={<i className="fas fa-eye" />} className="is-black" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { react2angular } from 'react2angular';
|
import { react2angular } from 'react2angular';
|
||||||
import { Loader } from '../base/loader';
|
import { Loader } from '../base/loader';
|
||||||
@ -6,10 +6,14 @@ import { IApplication } from '../../models/application';
|
|||||||
import { StoreListHeader } from './store-list-header';
|
import { StoreListHeader } from './store-list-header';
|
||||||
import { OrderItem } from './order-item';
|
import { OrderItem } from './order-item';
|
||||||
import { FabPagination } from '../base/fab-pagination';
|
import { FabPagination } from '../base/fab-pagination';
|
||||||
|
import OrderAPI from '../../api/order';
|
||||||
|
import { Order } from '../../models/order';
|
||||||
|
import { User } from '../../models/user';
|
||||||
|
|
||||||
declare const Application: IApplication;
|
declare const Application: IApplication;
|
||||||
|
|
||||||
interface OrdersDashboardProps {
|
interface OrdersDashboardProps {
|
||||||
|
currentUser: User,
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@ -21,15 +25,21 @@ type selectOption = { value: number, label: string };
|
|||||||
/**
|
/**
|
||||||
* This component shows a list of all orders from the store for the current user
|
* This component shows a list of all orders from the store for the current user
|
||||||
*/
|
*/
|
||||||
// TODO: delete next eslint disable
|
export const OrdersDashboard: React.FC<OrdersDashboardProps> = ({ currentUser, onError }) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
export const OrdersDashboard: React.FC<OrdersDashboardProps> = ({ onError }) => {
|
|
||||||
const { t } = useTranslation('public');
|
const { t } = useTranslation('public');
|
||||||
|
|
||||||
// TODO: delete next eslint disable
|
const [orders, setOrders] = useState<Array<Order>>([]);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
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 [totalCount, setTotalCount] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
OrderAPI.index({}).then(res => {
|
||||||
|
setPageCount(res.total_pages);
|
||||||
|
setTotalCount(res.total_count);
|
||||||
|
setOrders(res.data);
|
||||||
|
}).catch(onError);
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates sorting options to the react-select format
|
* Creates sorting options to the react-select format
|
||||||
@ -44,7 +54,26 @@ export const OrdersDashboard: React.FC<OrdersDashboardProps> = ({ onError }) =>
|
|||||||
* Display option: sorting
|
* Display option: sorting
|
||||||
*/
|
*/
|
||||||
const handleSorting = (option: selectOption) => {
|
const handleSorting = (option: selectOption) => {
|
||||||
console.log('Sort option:', option);
|
OrderAPI.index({ page: 1, sort: option.value ? 'ASC' : 'DESC' }).then(res => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setOrders(res.data);
|
||||||
|
setPageCount(res.total_pages);
|
||||||
|
setTotalCount(res.total_count);
|
||||||
|
}).catch(onError);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle orders pagination
|
||||||
|
*/
|
||||||
|
const handlePagination = (page: number) => {
|
||||||
|
if (page !== currentPage) {
|
||||||
|
OrderAPI.index({ page }).then(res => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
setOrders(res.data);
|
||||||
|
setPageCount(res.total_pages);
|
||||||
|
setTotalCount(res.total_count);
|
||||||
|
}).catch(onError);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -55,15 +84,17 @@ export const OrdersDashboard: React.FC<OrdersDashboardProps> = ({ onError }) =>
|
|||||||
|
|
||||||
<div className="store-list">
|
<div className="store-list">
|
||||||
<StoreListHeader
|
<StoreListHeader
|
||||||
productsCount={0}
|
productsCount={totalCount}
|
||||||
selectOptions={buildOptions()}
|
selectOptions={buildOptions()}
|
||||||
onSelectOptionsChange={handleSorting}
|
onSelectOptionsChange={handleSorting}
|
||||||
/>
|
/>
|
||||||
<div className="orders-list">
|
<div className="orders-list">
|
||||||
<OrderItem />
|
{orders.map(order => (
|
||||||
|
<OrderItem key={order.id} order={order} currentUser={currentUser} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{pageCount > 1 &&
|
{pageCount > 1 &&
|
||||||
<FabPagination pageCount={pageCount} currentPage={currentPage} selectPage={setCurrentPage} />
|
<FabPagination pageCount={pageCount} currentPage={currentPage} selectPage={handlePagination} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -78,4 +109,4 @@ const OrdersDashboardWrapper: React.FC<OrdersDashboardProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Application.Components.component('ordersDashboard', react2angular(OrdersDashboardWrapper, ['onError']));
|
Application.Components.component('ordersDashboard', react2angular(OrdersDashboardWrapper, ['onError', 'currentUser']));
|
||||||
|
@ -12,13 +12,14 @@ 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 { TDateISODate } from '../../typings/date-iso';
|
import OrderAPI from '../../api/order';
|
||||||
|
import { Order, OrderIndexFilter } from '../../models/order';
|
||||||
|
import { FabPagination } from '../base/fab-pagination';
|
||||||
|
|
||||||
declare const Application: IApplication;
|
declare const Application: IApplication;
|
||||||
|
|
||||||
interface OrdersProps {
|
interface OrdersProps {
|
||||||
currentUser?: User,
|
currentUser?: User,
|
||||||
onSuccess: (message: string) => void,
|
|
||||||
onError: (message: string) => void,
|
onError: (message: string) => void,
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@ -30,26 +31,44 @@ type selectOption = { value: number, label: string };
|
|||||||
/**
|
/**
|
||||||
* Option format, expected by checklist
|
* Option format, expected by checklist
|
||||||
*/
|
*/
|
||||||
type checklistOption = { value: number, label: string };
|
type checklistOption = { value: string, label: string };
|
||||||
|
|
||||||
|
const initFilters: OrderIndexFilter = {
|
||||||
|
reference: '',
|
||||||
|
states: [],
|
||||||
|
page: 1,
|
||||||
|
sort: 'DESC'
|
||||||
|
};
|
||||||
|
|
||||||
|
const FablabOrdersFilters = 'FablabOrdersFilters';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin list of orders
|
* Admin list of orders
|
||||||
*/
|
*/
|
||||||
// TODO: delete next eslint disable
|
const Orders: React.FC<OrdersProps> = ({ currentUser, onError }) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
|
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
const { register, getValues } = useForm();
|
const { register, setValue } = useForm();
|
||||||
|
|
||||||
const [filters, setFilters] = useImmer<Filters>(initFilters);
|
const [orders, setOrders] = useState<Array<Order>>([]);
|
||||||
const [clearFilters, setClearFilters] = useState<boolean>(false);
|
const [filters, setFilters] = useImmer<OrderIndexFilter>(window[FablabOrdersFilters] || initFilters);
|
||||||
const [accordion, setAccordion] = useState({});
|
const [accordion, setAccordion] = useState({});
|
||||||
|
const [pageCount, setPageCount] = useState<number>(0);
|
||||||
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
|
const [reference, setReference] = useState<string>(filters.reference);
|
||||||
|
const [states, setStates] = useState<Array<string>>(filters.states);
|
||||||
|
const [user, setUser] = useState<{ id: number, name?: string }>(filters.user);
|
||||||
|
const [periodFrom, setPeriodFrom] = useState<string>(filters.period_from);
|
||||||
|
const [periodTo, setPeriodTo] = useState<string>(filters.period_to);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyFilters();
|
window[FablabOrdersFilters] = filters;
|
||||||
setClearFilters(false);
|
OrderAPI.index(filters).then(res => {
|
||||||
}, [clearFilters]);
|
setPageCount(res.total_pages);
|
||||||
|
setTotalCount(res.total_count);
|
||||||
|
setOrders(res.data);
|
||||||
|
}).catch(onError);
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new order
|
* Create a new order
|
||||||
@ -59,21 +78,78 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusOptions: checklistOption[] = [
|
const statusOptions: checklistOption[] = [
|
||||||
{ value: 0, label: t('app.admin.store.orders.status.error') },
|
{ value: 'cart', label: t('app.admin.store.orders.state.cart') },
|
||||||
{ value: 1, label: t('app.admin.store.orders.status.canceled') },
|
{ value: 'paid', label: t('app.admin.store.orders.state.paid') },
|
||||||
{ value: 2, label: t('app.admin.store.orders.status.pending') },
|
{ value: 'payment_failed', label: t('app.admin.store.orders.state.payment_failed') },
|
||||||
{ value: 3, label: t('app.admin.store.orders.status.under_preparation') },
|
{ value: 'in_progress', label: t('app.admin.store.orders.state.in_progress') },
|
||||||
{ value: 4, label: t('app.admin.store.orders.status.paid') },
|
{ value: 'ready', label: t('app.admin.store.orders.state.ready') },
|
||||||
{ value: 5, label: t('app.admin.store.orders.status.ready') },
|
{ value: 'canceled', label: t('app.admin.store.orders.state.canceled') }
|
||||||
{ value: 6, label: t('app.admin.store.orders.status.collected') },
|
|
||||||
{ value: 7, label: t('app.admin.store.orders.status.refunded') }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply filters
|
* Apply filters
|
||||||
*/
|
*/
|
||||||
const applyFilters = () => {
|
const applyFilters = (filterType: string) => {
|
||||||
console.log('Apply filters:', filters);
|
return () => {
|
||||||
|
setFilters(draft => {
|
||||||
|
switch (filterType) {
|
||||||
|
case 'reference':
|
||||||
|
draft.reference = reference;
|
||||||
|
break;
|
||||||
|
case 'states':
|
||||||
|
draft.states = states;
|
||||||
|
break;
|
||||||
|
case 'user':
|
||||||
|
draft.user_id = user.id;
|
||||||
|
draft.user = user;
|
||||||
|
break;
|
||||||
|
case 'period':
|
||||||
|
if (periodFrom && periodTo) {
|
||||||
|
draft.period_from = periodFrom;
|
||||||
|
draft.period_to = periodTo;
|
||||||
|
} else {
|
||||||
|
draft.period_from = '';
|
||||||
|
draft.period_to = '';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear filter by type
|
||||||
|
*/
|
||||||
|
const removefilter = (filterType: string) => {
|
||||||
|
return () => {
|
||||||
|
setFilters(draft => {
|
||||||
|
draft.page = 1;
|
||||||
|
draft.sort = 'DESC';
|
||||||
|
switch (filterType) {
|
||||||
|
case 'reference':
|
||||||
|
draft.reference = '';
|
||||||
|
setReference('');
|
||||||
|
break;
|
||||||
|
case 'states':
|
||||||
|
draft.states = [];
|
||||||
|
setStates([]);
|
||||||
|
break;
|
||||||
|
case 'user':
|
||||||
|
delete draft.user_id;
|
||||||
|
delete draft.user;
|
||||||
|
setUser(null);
|
||||||
|
break;
|
||||||
|
case 'period':
|
||||||
|
draft.period_from = '';
|
||||||
|
draft.period_to = '';
|
||||||
|
setPeriodFrom(null);
|
||||||
|
setPeriodTo(null);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -81,8 +157,13 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
|
|||||||
*/
|
*/
|
||||||
const clearAllFilters = () => {
|
const clearAllFilters = () => {
|
||||||
setFilters(initFilters);
|
setFilters(initFilters);
|
||||||
setClearFilters(true);
|
setReference('');
|
||||||
console.log('Clear all filters');
|
setStates([]);
|
||||||
|
setUser(null);
|
||||||
|
setPeriodFrom(null);
|
||||||
|
setPeriodTo(null);
|
||||||
|
setValue('period_from', '');
|
||||||
|
setValue('period_to', '');
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -94,40 +175,54 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
|
|||||||
{ value: 1, label: t('app.admin.store.orders.sort.oldest') }
|
{ value: 1, label: t('app.admin.store.orders.sort.oldest') }
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display option: sorting
|
* Display option: sorting
|
||||||
*/
|
*/
|
||||||
const handleSorting = (option: selectOption) => {
|
const handleSorting = (option: selectOption) => {
|
||||||
console.log('Sort option:', option);
|
setFilters(draft => {
|
||||||
|
draft.sort = option.value ? 'ASC' : 'DESC';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter: by reference
|
||||||
|
*/
|
||||||
|
const handleReferenceChanged = (value: string) => {
|
||||||
|
setReference(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter: by status
|
* Filter: by status
|
||||||
*/
|
*/
|
||||||
const handleSelectStatus = (s: checklistOption, checked) => {
|
const handleSelectStatus = (s: checklistOption, checked: boolean) => {
|
||||||
const list = [...filters.status];
|
const list = [...states];
|
||||||
checked
|
checked
|
||||||
? list.push(s)
|
? list.push(s.value)
|
||||||
: list.splice(list.indexOf(s), 1);
|
: list.splice(list.indexOf(s.value), 1);
|
||||||
setFilters(draft => {
|
setStates(list);
|
||||||
return { ...draft, status: list };
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter: by member
|
* Filter: by member
|
||||||
*/
|
*/
|
||||||
const handleSelectMember = (userId: number) => {
|
const handleSelectMember = (user: User) => {
|
||||||
setFilters(draft => {
|
setUser(user);
|
||||||
return { ...draft, memberId: userId };
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter: by period
|
* Filter: by period
|
||||||
*/
|
*/
|
||||||
const handlePeriod = () => {
|
const handlePeriodChanged = (period: string) => {
|
||||||
console.log(getValues(['period_from', 'period_to']));
|
return (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (period === 'period_from') {
|
||||||
|
setPeriodFrom(value);
|
||||||
|
}
|
||||||
|
if (period === 'period_to') {
|
||||||
|
setPeriodTo(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -137,6 +232,15 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
|
|||||||
setAccordion({ ...accordion, [id]: state });
|
setAccordion({ ...accordion, [id]: state });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle orders pagination
|
||||||
|
*/
|
||||||
|
const handlePagination = (page: number) => {
|
||||||
|
setFilters(draft => {
|
||||||
|
draft.page = page;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='orders'>
|
<div className='orders'>
|
||||||
<header>
|
<header>
|
||||||
@ -155,6 +259,12 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
|
|||||||
<FabButton onClick={clearAllFilters} className="is-black">{t('app.admin.store.orders.filter_clear')}</FabButton>
|
<FabButton onClick={clearAllFilters} className="is-black">{t('app.admin.store.orders.filter_clear')}</FabButton>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<div>
|
||||||
|
{filters.reference && <div>{filters.reference} <i onClick={removefilter('reference')}>x</i></div>}
|
||||||
|
{filters.states.length > 0 && <div>{filters.states.join(', ')} <i onClick={removefilter('states')}>x</i></div>}
|
||||||
|
{filters.user_id > 0 && <div>{user?.name} <i onClick={removefilter('user')}>x</i></div>}
|
||||||
|
{filters.period_from && <div>{filters.period_from} - {filters.period_to} <i onClick={removefilter('period')}>x</i></div>}
|
||||||
|
</div>
|
||||||
<div className="accordion">
|
<div className="accordion">
|
||||||
<AccordionItem id={0}
|
<AccordionItem id={0}
|
||||||
isOpen={accordion[0]}
|
isOpen={accordion[0]}
|
||||||
@ -163,8 +273,8 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
|
|||||||
>
|
>
|
||||||
<div className='content'>
|
<div className='content'>
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<input type="text" />
|
<input type="text" value={reference} onChange={(event) => handleReferenceChanged(event.target.value)}/>
|
||||||
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
<FabButton onClick={applyFilters('reference')} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
@ -177,12 +287,12 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
|
|||||||
<div className="group u-scrollbar">
|
<div className="group u-scrollbar">
|
||||||
{statusOptions.map(s => (
|
{statusOptions.map(s => (
|
||||||
<label key={s.value}>
|
<label key={s.value}>
|
||||||
<input type="checkbox" checked={filters.status.some(o => o.label === s.label)} onChange={(event) => handleSelectStatus(s, event.target.checked)} />
|
<input type="checkbox" checked={states.some(o => o === s.value)} onChange={(event) => handleSelectStatus(s, event.target.checked)} />
|
||||||
<p>{s.label}</p>
|
<p>{s.label}</p>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
<FabButton onClick={applyFilters('states')} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
||||||
</div>
|
</div>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
<AccordionItem id={2}
|
<AccordionItem id={2}
|
||||||
@ -192,8 +302,8 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
|
|||||||
>
|
>
|
||||||
<div className='content'>
|
<div className='content'>
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<MemberSelect noHeader onSelected={handleSelectMember} />
|
<MemberSelect noHeader value={user as User} onSelected={handleSelectMember} />
|
||||||
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
<FabButton onClick={applyFilters('user')} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
@ -208,13 +318,17 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
|
|||||||
<FormInput id="period_from"
|
<FormInput id="period_from"
|
||||||
label={t('app.admin.store.orders.filter_period_from')}
|
label={t('app.admin.store.orders.filter_period_from')}
|
||||||
register={register}
|
register={register}
|
||||||
|
onChange={handlePeriodChanged('period_from')}
|
||||||
|
defaultValue={periodFrom}
|
||||||
type="date" />
|
type="date" />
|
||||||
<FormInput id="period_to"
|
<FormInput id="period_to"
|
||||||
label={t('app.admin.store.orders.filter_period_to')}
|
label={t('app.admin.store.orders.filter_period_to')}
|
||||||
register={register}
|
register={register}
|
||||||
|
onChange={handlePeriodChanged('period_to')}
|
||||||
|
defaultValue={periodTo}
|
||||||
type="date" />
|
type="date" />
|
||||||
</div>
|
</div>
|
||||||
<FabButton onClick={handlePeriod} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
<FabButton onClick={applyFilters('period')} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
@ -223,13 +337,19 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
|
|||||||
|
|
||||||
<div className="store-list">
|
<div className="store-list">
|
||||||
<StoreListHeader
|
<StoreListHeader
|
||||||
productsCount={0}
|
productsCount={totalCount}
|
||||||
selectOptions={buildOptions()}
|
selectOptions={buildOptions()}
|
||||||
|
selectValue={filters.sort === 'ASC' ? 1 : 0}
|
||||||
onSelectOptionsChange={handleSorting}
|
onSelectOptionsChange={handleSorting}
|
||||||
/>
|
/>
|
||||||
<div className="orders-list">
|
<div className="orders-list">
|
||||||
<OrderItem currentUser={currentUser} />
|
{orders.map(order => (
|
||||||
|
<OrderItem key={order.id} order={order} currentUser={currentUser} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{orders.length > 0 &&
|
||||||
|
<FabPagination pageCount={pageCount} currentPage={filters.page} selectPage={handlePagination} />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -243,20 +363,4 @@ const OrdersWrapper: React.FC<OrdersProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Application.Components.component('orders', react2angular(OrdersWrapper, ['currentUser', 'onSuccess', 'onError']));
|
Application.Components.component('orders', react2angular(OrdersWrapper, ['currentUser', 'onError']));
|
||||||
|
|
||||||
interface Filters {
|
|
||||||
reference: string,
|
|
||||||
status: checklistOption[],
|
|
||||||
memberId: number,
|
|
||||||
period_from: TDateISODate,
|
|
||||||
period_to: TDateISODate
|
|
||||||
}
|
|
||||||
|
|
||||||
const initFilters: Filters = {
|
|
||||||
reference: '',
|
|
||||||
status: [],
|
|
||||||
memberId: null,
|
|
||||||
period_from: null,
|
|
||||||
period_to: null
|
|
||||||
};
|
|
||||||
|
@ -20,12 +20,15 @@ import ProductAPI from '../../api/product';
|
|||||||
import { Plus } from 'phosphor-react';
|
import { Plus } from 'phosphor-react';
|
||||||
import { ProductStockForm } from './product-stock-form';
|
import { ProductStockForm } from './product-stock-form';
|
||||||
import ProductLib from '../../lib/product';
|
import ProductLib from '../../lib/product';
|
||||||
|
import { UnsavedFormAlert } from '../form/unsaved-form-alert';
|
||||||
|
import { UIRouter } from '@uirouter/angularjs';
|
||||||
|
|
||||||
interface ProductFormProps {
|
interface ProductFormProps {
|
||||||
product: Product,
|
product: Product,
|
||||||
title: string,
|
title: string,
|
||||||
onSuccess: (product: Product) => void,
|
onSuccess: (product: Product) => void,
|
||||||
onError: (message: string) => void,
|
onError: (message: string) => void,
|
||||||
|
uiRouter: UIRouter
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,7 +45,7 @@ type checklistOption = { value: number, label: string };
|
|||||||
/**
|
/**
|
||||||
* Form component to create or update a product
|
* Form component to create or update a product
|
||||||
*/
|
*/
|
||||||
export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSuccess, onError }) => {
|
export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSuccess, onError, uiRouter }) => {
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
const { handleSubmit, register, control, formState, setValue, reset } = useForm<Product>({ defaultValues: { ...product } });
|
const { handleSubmit, register, control, formState, setValue, reset } = useForm<Product>({ defaultValues: { ...product } });
|
||||||
@ -54,7 +57,7 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ProductCategoryAPI.index().then(data => {
|
ProductCategoryAPI.index().then(data => {
|
||||||
setProductCategories(buildSelectOptions(new ProductLib().sortCategories(data)));
|
setProductCategories(buildSelectOptions(ProductLib.sortCategories(data)));
|
||||||
}).catch(onError);
|
}).catch(onError);
|
||||||
MachineAPI.index({ disabled: false }).then(data => {
|
MachineAPI.index({ disabled: false }).then(data => {
|
||||||
setMachines(buildChecklistOptions(data));
|
setMachines(buildChecklistOptions(data));
|
||||||
@ -225,12 +228,13 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<form className="product-form" onSubmit={handleSubmit(onSubmit)}>
|
<form className="product-form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<UnsavedFormAlert uiRouter={uiRouter} formState={formState} />
|
||||||
<div className='tabs'>
|
<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(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>
|
<p className={stockTab ? 'is-active' : ''} onClick={() => setStockTab(true)}>{t('app.admin.store.product_form.stock_management')}</p>
|
||||||
</div>
|
</div>
|
||||||
{stockTab
|
{stockTab
|
||||||
? <ProductStockForm product={product} register={register} control={control} formState={formState} onError={onError} onSuccess={onSuccess} />
|
? <ProductStockForm currentFormValues={output as Product} register={register} control={control} formState={formState} setValue={setValue} onError={onError} onSuccess={onSuccess} />
|
||||||
: <section>
|
: <section>
|
||||||
<div className="subgrid">
|
<div className="subgrid">
|
||||||
<FormInput id="name"
|
<FormInput id="name"
|
||||||
|
@ -1,38 +1,51 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Product } from '../../models/product';
|
import Select from 'react-select';
|
||||||
import { UseFormRegister } from 'react-hook-form';
|
import { PencilSimple, X } from 'phosphor-react';
|
||||||
import { Control, FormState } from 'react-hook-form/dist/types/form';
|
import { useFieldArray, UseFormRegister } from 'react-hook-form';
|
||||||
|
import { Control, FormState, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Product, ProductStockMovement, StockMovementReason, StockType } from '../../models/product';
|
||||||
import { HtmlTranslate } from '../base/html-translate';
|
import { HtmlTranslate } from '../base/html-translate';
|
||||||
import { FormSwitch } from '../form/form-switch';
|
import { FormSwitch } from '../form/form-switch';
|
||||||
import { FormInput } from '../form/form-input';
|
import { FormInput } from '../form/form-input';
|
||||||
import Select from 'react-select';
|
|
||||||
import { FabAlert } from '../base/fab-alert';
|
import { FabAlert } from '../base/fab-alert';
|
||||||
import { FabButton } from '../base/fab-button';
|
import { FabButton } from '../base/fab-button';
|
||||||
import { PencilSimple } from 'phosphor-react';
|
|
||||||
import { FabModal, ModalSize } from '../base/fab-modal';
|
|
||||||
import { ProductStockModal } from './product-stock-modal';
|
import { ProductStockModal } from './product-stock-modal';
|
||||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
|
||||||
import { FabStateLabel } from '../base/fab-state-label';
|
import { FabStateLabel } from '../base/fab-state-label';
|
||||||
|
import ProductAPI from '../../api/product';
|
||||||
|
import FormatLib from '../../lib/format';
|
||||||
|
import ProductLib from '../../lib/product';
|
||||||
|
|
||||||
interface ProductStockFormProps<TFieldValues, TContext extends object> {
|
interface ProductStockFormProps<TContext extends object> {
|
||||||
product: Product,
|
currentFormValues: Product,
|
||||||
register: UseFormRegister<TFieldValues>,
|
register: UseFormRegister<Product>,
|
||||||
control: Control<TFieldValues, TContext>,
|
control: Control<Product, TContext>,
|
||||||
formState: FormState<TFieldValues>,
|
formState: FormState<Product>,
|
||||||
|
setValue: UseFormSetValue<Product>,
|
||||||
onSuccess: (product: Product) => void,
|
onSuccess: (product: Product) => void,
|
||||||
onError: (message: string) => void,
|
onError: (message: string) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LOW_STOCK_THRESHOLD = 30;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Form tab to manage a product's stock
|
* Form tab to manage a product's stock
|
||||||
*/
|
*/
|
||||||
export const ProductStockForm = <TFieldValues extends FieldValues, TContext extends object> ({ product, register, control, formState, onError, onSuccess }: ProductStockFormProps<TFieldValues, TContext>) => {
|
export const ProductStockForm = <TContext extends object> ({ currentFormValues, register, control, formState, setValue, onError }: ProductStockFormProps<TContext>) => {
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
const [activeThreshold, setActiveThreshold] = useState<boolean>(false);
|
const [activeThreshold, setActiveThreshold] = useState<boolean>(currentFormValues.low_stock_threshold != null);
|
||||||
// is the modal open?
|
// is the update stock modal open?
|
||||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
|
const [stockMovements, setStockMovements] = useState<Array<ProductStockMovement>>([]);
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({ control, name: 'product_stock_movements_attributes' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentFormValues?.id) return;
|
||||||
|
|
||||||
|
ProductAPI.stockMovements(currentFormValues.id).then(setStockMovements).catch(onError);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Styles the React-select component
|
// Styles the React-select component
|
||||||
const customStyles = {
|
const customStyles = {
|
||||||
@ -47,41 +60,38 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
type selectOption = { value: number, label: string };
|
type reasonSelectOption = { value: StockMovementReason, label: string };
|
||||||
/**
|
/**
|
||||||
* Creates sorting options to the react-select format
|
* Creates sorting options to the react-select format
|
||||||
*/
|
*/
|
||||||
const buildEventsOptions = (): Array<selectOption> => {
|
const buildReasonsOptions = (): Array<reasonSelectOption> => {
|
||||||
return [
|
return (['inward_stock', 'returned', 'cancelled', 'inventory_fix', 'sold', 'missing', 'damaged'] as Array<StockMovementReason>).map(key => {
|
||||||
{ value: 0, label: t('app.admin.store.product_stock_form.events.inward_stock') },
|
return { value: key, label: t(ProductLib.stockMovementReasonTrKey(key)) };
|
||||||
{ value: 1, label: t('app.admin.store.product_stock_form.events.returned') },
|
});
|
||||||
{ value: 2, label: t('app.admin.store.product_stock_form.events.canceled') },
|
|
||||||
{ value: 3, label: t('app.admin.store.product_stock_form.events.sold') },
|
|
||||||
{ value: 4, label: t('app.admin.store.product_stock_form.events.missing') },
|
|
||||||
{ value: 5, label: t('app.admin.store.product_stock_form.events.damaged') }
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type typeSelectOption = { value: StockType, label: string };
|
||||||
/**
|
/**
|
||||||
* Creates sorting options to the react-select format
|
* Creates sorting options to the react-select format
|
||||||
*/
|
*/
|
||||||
const buildStocksOptions = (): Array<selectOption> => {
|
const buildStocksOptions = (): Array<typeSelectOption> => {
|
||||||
return [
|
return [
|
||||||
{ value: 0, label: t('app.admin.store.product_stock_form.internal') },
|
{ value: 'internal', label: t('app.admin.store.product_stock_form.internal') },
|
||||||
{ value: 1, label: t('app.admin.store.product_stock_form.external') },
|
{ value: 'external', label: t('app.admin.store.product_stock_form.external') },
|
||||||
{ value: 2, label: t('app.admin.store.product_stock_form.all') }
|
{ value: 'all', label: t('app.admin.store.product_stock_form.all') }
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On events option change
|
* On events option change
|
||||||
*/
|
*/
|
||||||
const eventsOptionsChange = (evt: selectOption) => {
|
const eventsOptionsChange = (evt: reasonSelectOption) => {
|
||||||
console.log('Event option:', evt);
|
console.log('Event option:', evt);
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* On stocks option change
|
* On stocks option change
|
||||||
*/
|
*/
|
||||||
const stocksOptionsChange = (evt: selectOption) => {
|
const stocksOptionsChange = (evt: typeSelectOption) => {
|
||||||
console.log('Stocks option:', evt);
|
console.log('Stocks option:', evt);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -90,38 +100,87 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
|
|||||||
*/
|
*/
|
||||||
const toggleStockThreshold = (checked: boolean) => {
|
const toggleStockThreshold = (checked: boolean) => {
|
||||||
setActiveThreshold(checked);
|
setActiveThreshold(checked);
|
||||||
|
setValue(
|
||||||
|
'low_stock_threshold',
|
||||||
|
(checked ? DEFAULT_LOW_STOCK_THRESHOLD : null)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens/closes the product category modal
|
* Opens/closes the product stock edition modal
|
||||||
*/
|
*/
|
||||||
const toggleModal = (): void => {
|
const toggleModal = (): void => {
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle stock threshold alert
|
* Triggered when a new product stock movement was added
|
||||||
*/
|
*/
|
||||||
const toggleStockThresholdAlert = (checked: boolean) => {
|
const onNewStockMovement = (movement): void => {
|
||||||
console.log('Low stock notification:', checked);
|
append({ ...movement });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the data of the update of the stock for the current product
|
||||||
|
*/
|
||||||
|
const lastStockUpdate = () => {
|
||||||
|
if (stockMovements[0]) {
|
||||||
|
return stockMovements[0].date;
|
||||||
|
} else {
|
||||||
|
return currentFormValues?.created_at || new Date();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className='product-stock-form'>
|
<section className='product-stock-form'>
|
||||||
<h4>Stock à jour <span>00/00/0000 - 00H30</span></h4>
|
<h4>{t('app.admin.store.product_stock_form.stock_up_to_date')}
|
||||||
|
<span>{t('app.admin.store.product_stock_form.date_time', {
|
||||||
|
DATE: FormatLib.date(lastStockUpdate()),
|
||||||
|
TIME: FormatLib.time((lastStockUpdate()))
|
||||||
|
})}</span>
|
||||||
|
</h4>
|
||||||
<div></div>
|
<div></div>
|
||||||
<div className="stock-item">
|
<div className="stock-item">
|
||||||
<p className='title'>Product name</p>
|
<p className='title'>{currentFormValues?.name}</p>
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<span>{t('app.admin.store.product_stock_form.internal')}</span>
|
<span>{t('app.admin.store.product_stock_form.internal')}</span>
|
||||||
<p>00</p>
|
<p>{currentFormValues?.stock?.internal}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<span>{t('app.admin.store.product_stock_form.external')}</span>
|
<span>{t('app.admin.store.product_stock_form.external')}</span>
|
||||||
<p>000</p>
|
<p>{currentFormValues?.stock?.external}</p>
|
||||||
</div>
|
</div>
|
||||||
<FabButton onClick={toggleModal} icon={<PencilSimple size={20} weight="fill" />} className="is-black">Modifier</FabButton>
|
<FabButton onClick={toggleModal} icon={<PencilSimple size={20} weight="fill" />} className="is-black">Modifier</FabButton>
|
||||||
</div>
|
</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 />
|
<hr />
|
||||||
|
|
||||||
<div className="threshold-data">
|
<div className="threshold-data">
|
||||||
@ -139,19 +198,18 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
|
|||||||
{activeThreshold && <>
|
{activeThreshold && <>
|
||||||
<FabStateLabel>{t('app.admin.store.product_stock_form.low_stock')}</FabStateLabel>
|
<FabStateLabel>{t('app.admin.store.product_stock_form.low_stock')}</FabStateLabel>
|
||||||
<div className="threshold-data-content">
|
<div className="threshold-data-content">
|
||||||
<FormInput id="threshold"
|
<FormInput id="low_stock_threshold"
|
||||||
type="number"
|
type="number"
|
||||||
register={register}
|
register={register}
|
||||||
rules={{ required: true, min: 1 }}
|
rules={{ required: activeThreshold, min: 1 }}
|
||||||
step={1}
|
step={1}
|
||||||
formState={formState}
|
formState={formState}
|
||||||
label={t('app.admin.store.product_stock_form.threshold_level')} />
|
nullable
|
||||||
<FormSwitch control={control}
|
label={t('app.admin.store.product_stock_form.threshold_level')} />
|
||||||
id="threshold_alert"
|
<FormSwitch control={control}
|
||||||
formState={formState}
|
id="low_stock_alert"
|
||||||
label={t('app.admin.store.product_stock_form.threshold_alert')}
|
formState={formState}
|
||||||
defaultValue={activeThreshold}
|
label={t('app.admin.store.product_stock_form.threshold_alert')} />
|
||||||
onChange={toggleStockThresholdAlert} />
|
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
@ -163,7 +221,7 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
|
|||||||
<div className='sort-events'>
|
<div className='sort-events'>
|
||||||
<p>{t('app.admin.store.product_stock_form.event_type')}</p>
|
<p>{t('app.admin.store.product_stock_form.event_type')}</p>
|
||||||
<Select
|
<Select
|
||||||
options={buildEventsOptions()}
|
options={buildReasonsOptions()}
|
||||||
onChange={evt => eventsOptionsChange(evt)}
|
onChange={evt => eventsOptionsChange(evt)}
|
||||||
styles={customStyles}
|
styles={customStyles}
|
||||||
/>
|
/>
|
||||||
@ -177,33 +235,29 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stock-history">
|
{stockMovements.map(movement => <div className="stock-history" key={movement.id}>
|
||||||
<div className="stock-item">
|
<div className="stock-item">
|
||||||
<p className='title'>Product name</p>
|
<p className='title'>{currentFormValues.name}</p>
|
||||||
<p>00/00/0000</p>
|
<p>{FormatLib.date(movement.date)}</p>
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<span>[stock type]</span>
|
<span>{t(`app.admin.store.product_stock_form.${movement.stock_type}`)}</span>
|
||||||
<p>00</p>
|
<p>{ProductLib.absoluteStockMovement(movement.quantity, movement.reason)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<span>{t('app.admin.store.product_stock_form.event_type')}</span>
|
<span>{t('app.admin.store.product_stock_form.reason')}</span>
|
||||||
<p>[event type]</p>
|
<p>{t(ProductLib.stockMovementReasonTrKey(movement.reason))}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="group">
|
<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>000</p>
|
<p>{movement.remaining_stock}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
|
<ProductStockModal onError={onError}
|
||||||
<FabModal title={t('app.admin.store.product_stock_form.modal_title')}
|
onSuccess={onNewStockMovement}
|
||||||
width={ModalSize.large}
|
isOpen={isOpen}
|
||||||
isOpen={isOpen}
|
toggleModal={toggleModal} />
|
||||||
toggleModal={toggleModal}
|
|
||||||
closeButton>
|
|
||||||
<ProductStockModal product={product} register={register} control={control} formState={formState} onError={onError} onSuccess={onSuccess} />
|
|
||||||
</FabModal>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,22 +1,27 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Product } from '../../models/product';
|
import {
|
||||||
import { UseFormRegister } from 'react-hook-form';
|
ProductStockMovement,
|
||||||
import { Control, FormState } from 'react-hook-form/dist/types/form';
|
stockMovementInReasons,
|
||||||
|
stockMovementOutReasons,
|
||||||
|
StockMovementReason,
|
||||||
|
StockType
|
||||||
|
} from '../../models/product';
|
||||||
import { FormSelect } from '../form/form-select';
|
import { FormSelect } from '../form/form-select';
|
||||||
import { FormInput } from '../form/form-input';
|
import { FormInput } from '../form/form-input';
|
||||||
import { FabButton } from '../base/fab-button';
|
import { FabButton } from '../base/fab-button';
|
||||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
import { FabModal, ModalSize } from '../base/fab-modal';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import ProductLib from '../../lib/product';
|
||||||
|
|
||||||
type selectOption = { value: number, label: string };
|
type reasonSelectOption = { value: StockMovementReason, label: string };
|
||||||
|
type typeSelectOption = { value: StockType, label: string };
|
||||||
|
|
||||||
interface ProductStockModalProps<TFieldValues, TContext extends object> {
|
interface ProductStockModalProps {
|
||||||
product: Product,
|
onSuccess: (movement: ProductStockMovement) => void,
|
||||||
register: UseFormRegister<TFieldValues>,
|
onError: (message: string) => void,
|
||||||
control: Control<TFieldValues, TContext>,
|
isOpen: boolean,
|
||||||
formState: FormState<TFieldValues>,
|
toggleModal: () => void,
|
||||||
onSuccess: (product: Product) => void,
|
|
||||||
onError: (message: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -24,11 +29,13 @@ interface ProductStockModalProps<TFieldValues, TContext extends object> {
|
|||||||
*/
|
*/
|
||||||
// TODO: delete next eslint disable
|
// TODO: delete next eslint disable
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
export const ProductStockModal = <TFieldValues extends FieldValues, TContext extends object> ({ product, register, control, formState, onError, onSuccess }: ProductStockModalProps<TFieldValues, TContext>) => {
|
export const ProductStockModal: React.FC<ProductStockModalProps> = ({ onError, onSuccess, isOpen, toggleModal }) => {
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
const [movement, setMovement] = useState<'in' | 'out'>('in');
|
const [movement, setMovement] = useState<'in' | 'out'>('in');
|
||||||
|
|
||||||
|
const { handleSubmit, register, control, formState } = useForm<ProductStockMovement>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle between adding or removing product from stock
|
* Toggle between adding or removing product from stock
|
||||||
*/
|
*/
|
||||||
@ -37,63 +44,75 @@ export const ProductStockModal = <TFieldValues extends FieldValues, TContext ext
|
|||||||
setMovement(type);
|
setMovement(type);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the user validates the new stock movement.
|
||||||
|
* We do not use handleSubmit() directly to prevent the propagaion of the "submit" event to the parent form
|
||||||
|
*/
|
||||||
|
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
if (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
return handleSubmit((data: ProductStockMovement) => {
|
||||||
|
onSuccess(data);
|
||||||
|
toggleModal();
|
||||||
|
})(event);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates sorting options to the react-select format
|
* Creates sorting options to the react-select format
|
||||||
*/
|
*/
|
||||||
const buildEventsOptions = (): Array<selectOption> => {
|
const buildEventsOptions = (): Array<reasonSelectOption> => {
|
||||||
let options = [];
|
return (movement === 'in' ? stockMovementInReasons : stockMovementOutReasons).map(key => {
|
||||||
movement === 'in'
|
return { value: key, label: t(ProductLib.stockMovementReasonTrKey(key)) };
|
||||||
? options = [
|
});
|
||||||
{ value: 0, label: t('app.admin.store.product_stock_modal.events.inward_stock') },
|
|
||||||
{ value: 1, label: t('app.admin.store.product_stock_modal.events.returned') },
|
|
||||||
{ value: 2, label: t('app.admin.store.product_stock_modal.events.canceled') }
|
|
||||||
]
|
|
||||||
: options = [
|
|
||||||
{ value: 0, label: t('app.admin.store.product_stock_modal.events.sold') },
|
|
||||||
{ value: 1, label: t('app.admin.store.product_stock_modal.events.missing') },
|
|
||||||
{ value: 2, label: t('app.admin.store.product_stock_modal.events.damaged') }
|
|
||||||
];
|
|
||||||
return options;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Creates sorting options to the react-select format
|
* Creates sorting options to the react-select format
|
||||||
*/
|
*/
|
||||||
const buildStocksOptions = (): Array<selectOption> => {
|
const buildStocksOptions = (): Array<typeSelectOption> => {
|
||||||
return [
|
return [
|
||||||
{ value: 0, label: t('app.admin.store.product_stock_modal.internal') },
|
{ value: 'internal', label: t('app.admin.store.product_stock_modal.internal') },
|
||||||
{ value: 1, label: t('app.admin.store.product_stock_modal.external') }
|
{ value: 'external', label: t('app.admin.store.product_stock_modal.external') }
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className='product-stock-modal'>
|
<FabModal title={t('app.admin.store.product_stock_modal.modal_title')}
|
||||||
<p className='subtitle'>{t('app.admin.store.product_stock_modal.new_event')}</p>
|
width={ModalSize.large}
|
||||||
<div className="movement">
|
isOpen={isOpen}
|
||||||
<button onClick={(evt) => toggleMovementType(evt, 'in')} className={movement === 'in' ? 'is-active' : ''}>
|
toggleModal={toggleModal}
|
||||||
{t('app.admin.store.product_stock_modal.addition')}
|
className="product-stock-modal"
|
||||||
</button>
|
closeButton>
|
||||||
<button onClick={(evt) => toggleMovementType(evt, 'out')} className={movement === 'out' ? 'is-active' : ''}>
|
<form onSubmit={onSubmit}>
|
||||||
{t('app.admin.store.product_stock_modal.withdrawal')}
|
<p className='subtitle'>{t('app.admin.store.product_stock_modal.new_event')}</p>
|
||||||
</button>
|
<div className="movement">
|
||||||
</div>
|
<button onClick={(evt) => toggleMovementType(evt, 'in')} className={movement === 'in' ? 'is-active' : ''}>
|
||||||
<FormSelect options={buildStocksOptions()}
|
{t('app.admin.store.product_stock_modal.addition')}
|
||||||
control={control}
|
</button>
|
||||||
id="updated_stock_type"
|
<button onClick={(evt) => toggleMovementType(evt, 'out')} className={movement === 'out' ? 'is-active' : ''}>
|
||||||
formState={formState}
|
{t('app.admin.store.product_stock_modal.withdrawal')}
|
||||||
label={t('app.admin.store.product_stock_modal.stocks')} />
|
</button>
|
||||||
<FormInput id="updated_stock_quantity"
|
</div>
|
||||||
type="number"
|
<FormSelect options={buildStocksOptions()}
|
||||||
register={register}
|
control={control}
|
||||||
rules={{ required: true, min: 1 }}
|
id="stock_type"
|
||||||
step={1}
|
formState={formState}
|
||||||
formState={formState}
|
label={t('app.admin.store.product_stock_modal.stocks')} />
|
||||||
label={t('app.admin.store.product_stock_modal.quantity')} />
|
<FormInput id="quantity"
|
||||||
<FormSelect options={buildEventsOptions()}
|
type="number"
|
||||||
control={control}
|
register={register}
|
||||||
id="updated_stock_event"
|
rules={{ required: true, min: 1 }}
|
||||||
formState={formState}
|
step={1}
|
||||||
label={t('app.admin.store.product_stock_modal.event_type')} />
|
formState={formState}
|
||||||
<FabButton type='submit'>{t('app.admin.store.product_stock_modal.update_stock')} </FabButton>
|
label={t('app.admin.store.product_stock_modal.quantity')} />
|
||||||
</form>
|
<FormSelect options={buildEventsOptions()}
|
||||||
|
control={control}
|
||||||
|
id="reason"
|
||||||
|
formState={formState}
|
||||||
|
label={t('app.admin.store.product_stock_modal.reason_type')} />
|
||||||
|
<FabButton type='submit'>{t('app.admin.store.product_stock_modal.update_stock')} </FabButton>
|
||||||
|
</form>
|
||||||
|
</FabModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -57,7 +57,7 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ProductCategoryAPI.index().then(data => {
|
ProductCategoryAPI.index().then(data => {
|
||||||
setProductCategories(new ProductLib().sortCategories(data));
|
setProductCategories(ProductLib.sortCategories(data));
|
||||||
}).catch(onError);
|
}).catch(onError);
|
||||||
|
|
||||||
MachineAPI.index({ disabled: false }).then(data => {
|
MachineAPI.index({ disabled: false }).then(data => {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { IApplication } from '../../models/application';
|
import { IApplication } from '../../models/application';
|
||||||
import { User } from '../../models/user';
|
import { User } from '../../models/user';
|
||||||
@ -6,30 +6,35 @@ import { react2angular } from 'react2angular';
|
|||||||
import { Loader } from '../base/loader';
|
import { Loader } from '../base/loader';
|
||||||
import noImage from '../../../../images/no_image.png';
|
import noImage from '../../../../images/no_image.png';
|
||||||
import { FabStateLabel } from '../base/fab-state-label';
|
import { FabStateLabel } from '../base/fab-state-label';
|
||||||
import Select from 'react-select';
|
import OrderAPI from '../../api/order';
|
||||||
|
import { Order } from '../../models/order';
|
||||||
|
import FormatLib from '../../lib/format';
|
||||||
|
import OrderLib from '../../lib/order';
|
||||||
|
import { OrderActions } from './order-actions';
|
||||||
|
|
||||||
declare const Application: IApplication;
|
declare const Application: IApplication;
|
||||||
|
|
||||||
interface ShowOrderProps {
|
interface ShowOrderProps {
|
||||||
orderRef: string,
|
orderId: string,
|
||||||
currentUser?: User,
|
currentUser?: User,
|
||||||
|
onSuccess: (message: string) => void,
|
||||||
onError: (message: string) => void,
|
onError: (message: string) => void,
|
||||||
onSuccess: (message: string) => void
|
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Option format, expected by react-select
|
|
||||||
* @see https://github.com/JedWatson/react-select
|
|
||||||
*/
|
|
||||||
type selectOption = { value: number, label: string };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component shows an order details
|
* This component shows an order details
|
||||||
*/
|
*/
|
||||||
// TODO: delete next eslint disable
|
export const ShowOrder: React.FC<ShowOrderProps> = ({ orderId, currentUser, onSuccess, onError }) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
export const ShowOrder: React.FC<ShowOrderProps> = ({ orderRef, currentUser, onError, onSuccess }) => {
|
|
||||||
const { t } = useTranslation('shared');
|
const { t } = useTranslation('shared');
|
||||||
|
|
||||||
|
const [order, setOrder] = useState<Order>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
OrderAPI.get(orderId).then(data => {
|
||||||
|
setOrder(data);
|
||||||
|
}).catch(onError);
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current operator has administrative rights or is a normal member
|
* Check if the current operator has administrative rights or is a normal member
|
||||||
*/
|
*/
|
||||||
@ -38,96 +43,85 @@ export const ShowOrder: React.FC<ShowOrderProps> = ({ orderRef, currentUser, onE
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates sorting options to the react-select format
|
* Returns order's payment info
|
||||||
*/
|
*/
|
||||||
const buildOptions = (): Array<selectOption> => {
|
const paymentInfo = (): string => {
|
||||||
return [
|
let paymentVerbose = '';
|
||||||
{ value: 0, label: t('app.shared.store.show_order.status.error') },
|
if (order.payment_method === 'card') {
|
||||||
{ value: 1, label: t('app.shared.store.show_order.status.canceled') },
|
paymentVerbose = t('app.shared.store.show_order.payment.settlement_by_debit_card');
|
||||||
{ value: 2, label: t('app.shared.store.show_order.status.pending') },
|
} else if (order.payment_method === 'wallet') {
|
||||||
{ value: 3, label: t('app.shared.store.show_order.status.under_preparation') },
|
paymentVerbose = t('app.shared.store.show_order.payment.settlement_by_wallet');
|
||||||
{ value: 4, label: t('app.shared.store.show_order.status.paid') },
|
} else {
|
||||||
{ value: 5, label: t('app.shared.store.show_order.status.ready') },
|
paymentVerbose = t('app.shared.store.show_order.payment.settlement_done_at_the_reception');
|
||||||
{ value: 6, label: t('app.shared.store.show_order.status.collected') },
|
|
||||||
{ value: 7, label: t('app.shared.store.show_order.status.refunded') }
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback after selecting an action
|
|
||||||
*/
|
|
||||||
const handleAction = (action: selectOption) => {
|
|
||||||
console.log('Action:', action);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Styles the React-select component
|
|
||||||
const customStyles = {
|
|
||||||
control: base => ({
|
|
||||||
...base,
|
|
||||||
width: '20ch',
|
|
||||||
backgroundColor: 'transparent'
|
|
||||||
}),
|
|
||||||
indicatorSeparator: () => ({
|
|
||||||
display: 'none'
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a className according to the status
|
|
||||||
*/
|
|
||||||
const statusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'error':
|
|
||||||
return 'error';
|
|
||||||
case 'canceled':
|
|
||||||
return 'canceled';
|
|
||||||
case 'pending' || 'under_preparation':
|
|
||||||
return 'pending';
|
|
||||||
default:
|
|
||||||
return 'normal';
|
|
||||||
}
|
}
|
||||||
|
paymentVerbose += ' ' + t('app.shared.store.show_order.payment.on_DATE_at_TIME', {
|
||||||
|
DATE: FormatLib.date(order.payment_date),
|
||||||
|
TIME: FormatLib.time(order.payment_date)
|
||||||
|
});
|
||||||
|
if (order.payment_method !== 'wallet') {
|
||||||
|
paymentVerbose += ' ' + t('app.shared.store.show_order.payment.for_an_amount_of_AMOUNT', { AMOUNT: FormatLib.price(order.paid_total) });
|
||||||
|
}
|
||||||
|
if (order.wallet_amount) {
|
||||||
|
if (order.payment_method === 'wallet') {
|
||||||
|
paymentVerbose += ' ' + t('app.shared.store.show_order.payment.for_an_amount_of_AMOUNT', { AMOUNT: FormatLib.price(order.wallet_amount) });
|
||||||
|
} else {
|
||||||
|
paymentVerbose += ' ' + t('app.shared.store.show_order.payment.and') + ' ' + t('app.shared.store.show_order.payment.by_wallet') + ' ' +
|
||||||
|
t('app.shared.store.show_order.payment.for_an_amount_of_AMOUNT', { AMOUNT: FormatLib.price(order.wallet_amount) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paymentVerbose;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback after action success
|
||||||
|
*/
|
||||||
|
const handleActionSuccess = (data: Order, message: string) => {
|
||||||
|
setOrder(data);
|
||||||
|
onSuccess(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='show-order'>
|
<div className='show-order'>
|
||||||
<header>
|
<header>
|
||||||
<h2>[order.ref]</h2>
|
<h2>[{order.reference}]</h2>
|
||||||
<div className="grpBtn">
|
<div className="grpBtn">
|
||||||
{isPrivileged() &&
|
{isPrivileged() &&
|
||||||
<Select
|
<OrderActions order={order} onSuccess={handleActionSuccess} onError={onError} />
|
||||||
options={buildOptions()}
|
|
||||||
onChange={option => handleAction(option)}
|
|
||||||
styles={customStyles}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
<a href={''}
|
{order?.invoice_id && (
|
||||||
target='_blank'
|
<a href={`/api/invoices/${order?.invoice_id}/download`}
|
||||||
className='fab-button is-black'
|
target='_blank'
|
||||||
rel='noreferrer'>
|
className='fab-button is-black'
|
||||||
{t('app.shared.store.show_order.see_invoice')}
|
rel='noreferrer'>
|
||||||
</a>
|
{t('app.shared.store.show_order.see_invoice')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="client-info">
|
<div className="client-info">
|
||||||
<label>{t('app.shared.store.show_order.tracking')}</label>
|
<label>{t('app.shared.store.show_order.tracking')}</label>
|
||||||
<div className="content">
|
<div className="content">
|
||||||
{isPrivileged() &&
|
{isPrivileged() && order.user &&
|
||||||
<div className='group'>
|
<div className='group'>
|
||||||
<span>{t('app.shared.store.show_order.client')}</span>
|
<span>{t('app.shared.store.show_order.client')}</span>
|
||||||
<p>order.user.name</p>
|
<p>{order.user.name}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div className='group'>
|
<div className='group'>
|
||||||
<span>{t('app.shared.store.show_order.created_at')}</span>
|
<span>{t('app.shared.store.show_order.created_at')}</span>
|
||||||
<p>order.created_at</p>
|
<p>{FormatLib.date(order.created_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='group'>
|
<div className='group'>
|
||||||
<span>{t('app.shared.store.show_order.last_update')}</span>
|
<span>{t('app.shared.store.show_order.last_update')}</span>
|
||||||
<p>order.???</p>
|
<p>{FormatLib.date(order.updated_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<FabStateLabel status={statusColor('error')} background>
|
<FabStateLabel status={OrderLib.statusColor(order)} background>
|
||||||
order.state
|
{t(`app.shared.store.show_order.state.${OrderLib.statusText(order)}`)}
|
||||||
</FabStateLabel>
|
</FabStateLabel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -135,43 +129,48 @@ export const ShowOrder: React.FC<ShowOrderProps> = ({ orderRef, currentUser, onE
|
|||||||
<div className="cart">
|
<div className="cart">
|
||||||
<label>{t('app.shared.store.show_order.cart')}</label>
|
<label>{t('app.shared.store.show_order.cart')}</label>
|
||||||
<div>
|
<div>
|
||||||
{/* loop sur les articles du panier */}
|
{order.order_items_attributes.map(item => (
|
||||||
<article className='store-cart-list-item'>
|
<article className='store-cart-list-item' key={item.id}>
|
||||||
<div className='picture'>
|
<div className='picture'>
|
||||||
<img alt=''src={noImage} />
|
<img alt=''src={item.orderable_main_image_url || noImage} />
|
||||||
</div>
|
|
||||||
<div className="ref">
|
|
||||||
<span>{t('app.shared.store.show_order.reference_short')} orderable_id?</span>
|
|
||||||
<p>o.orderable_name</p>
|
|
||||||
</div>
|
|
||||||
<div className="actions">
|
|
||||||
<div className='price'>
|
|
||||||
<p>o.amount</p>
|
|
||||||
<span>/ {t('app.shared.store.show_order.unit')}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ref">
|
||||||
<span className="count">o.quantity</span>
|
<span>{t('app.shared.store.show_order.reference_short')} {item.orderable_ref || ''}</span>
|
||||||
|
<p>{item.orderable_name}</p>
|
||||||
<div className='total'>
|
|
||||||
<span>{t('app.shared.store.show_order.item_total')}</span>
|
|
||||||
<p>o.quantity * o.amount</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="actions">
|
||||||
</article>
|
<div className='price'>
|
||||||
|
<p>{FormatLib.price(item.amount)}</p>
|
||||||
|
<span>/ {t('app.shared.store.show_order.unit')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="count">{item.quantity}</span>
|
||||||
|
|
||||||
|
<div className='total'>
|
||||||
|
<span>{t('app.shared.store.show_order.item_total')}</span>
|
||||||
|
<p>{FormatLib.price(OrderLib.itemAmount(item))}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="subgrid">
|
<div className="subgrid">
|
||||||
<div className="payment-info">
|
<div className="payment-info">
|
||||||
<label>{t('app.shared.store.show_order.payment_informations')}</label>
|
<label>{t('app.shared.store.show_order.payment_informations')}</label>
|
||||||
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Ipsum rerum commodi quaerat possimus! Odit, harum.</p>
|
{order.invoice_id && <p>{paymentInfo()}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="amount">
|
<div className="amount">
|
||||||
<label>{t('app.shared.store.show_order.amount')}</label>
|
<label>{t('app.shared.store.show_order.amount')}</label>
|
||||||
<p>{t('app.shared.store.show_order.products_total')}<span>order.amount</span></p>
|
<p>{t('app.shared.store.show_order.products_total')}<span>{FormatLib.price(OrderLib.totalBeforeOfferedAmount(order))}</span></p>
|
||||||
<p className='gift'>{t('app.shared.store.show_order.gift_total')}<span>-order.amount</span></p>
|
{OrderLib.hasOfferedItem(order) &&
|
||||||
<p>{t('app.shared.store.show_order.coupon')}<span>order.amount</span></p>
|
<p className='gift'>{t('app.shared.store.show_order.gift_total')}<span>-{FormatLib.price(OrderLib.offeredAmount(order))}</span></p>
|
||||||
<p className='total'>{t('app.shared.store.show_order.cart_total')} <span>order.total</span></p>
|
}
|
||||||
|
{order.coupon &&
|
||||||
|
<p>{t('app.shared.store.show_order.coupon')}<span>-{FormatLib.price(OrderLib.couponAmount(order))}</span></p>
|
||||||
|
}
|
||||||
|
<p className='total'>{t('app.shared.store.show_order.cart_total')} <span>{FormatLib.price(OrderLib.paidTotal(order))}</span></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -186,4 +185,4 @@ const ShowOrderWrapper: React.FC<ShowOrderProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Application.Components.component('showOrder', react2angular(ShowOrderWrapper, ['orderRef', 'currentUser', 'onError', 'onSuccess']));
|
Application.Components.component('showOrder', react2angular(ShowOrderWrapper, ['orderId', 'currentUser', 'onError', 'onSuccess']));
|
||||||
|
@ -7,6 +7,7 @@ interface StoreListHeaderProps {
|
|||||||
productsCount: number,
|
productsCount: number,
|
||||||
selectOptions: selectOption[],
|
selectOptions: selectOption[],
|
||||||
onSelectOptionsChange: (option: selectOption) => void,
|
onSelectOptionsChange: (option: selectOption) => void,
|
||||||
|
selectValue?: number,
|
||||||
switchLabel?: string,
|
switchLabel?: string,
|
||||||
switchChecked?: boolean,
|
switchChecked?: boolean,
|
||||||
onSwitch?: (boolean) => void
|
onSwitch?: (boolean) => void
|
||||||
@ -20,7 +21,7 @@ interface StoreListHeaderProps {
|
|||||||
/**
|
/**
|
||||||
* Renders an accordion item
|
* Renders an accordion item
|
||||||
*/
|
*/
|
||||||
export const StoreListHeader: React.FC<StoreListHeaderProps> = ({ productsCount, selectOptions, onSelectOptionsChange, switchLabel, switchChecked, onSwitch }) => {
|
export const StoreListHeader: React.FC<StoreListHeaderProps> = ({ productsCount, selectOptions, onSelectOptionsChange, switchLabel, switchChecked, onSwitch, selectValue }) => {
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
// Styles the React-select component
|
// Styles the React-select component
|
||||||
@ -47,6 +48,7 @@ export const StoreListHeader: React.FC<StoreListHeaderProps> = ({ productsCount,
|
|||||||
<Select
|
<Select
|
||||||
options={selectOptions}
|
options={selectOptions}
|
||||||
onChange={evt => onSelectOptionsChange(evt)}
|
onChange={evt => onSelectOptionsChange(evt)}
|
||||||
|
value={selectOptions.find(option => option.value === selectValue)}
|
||||||
styles={customStyles}
|
styles={customStyles}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,7 +6,8 @@ import { User } from '../../models/user';
|
|||||||
|
|
||||||
interface MemberSelectProps {
|
interface MemberSelectProps {
|
||||||
defaultUser?: User,
|
defaultUser?: User,
|
||||||
onSelected?: (userId: number) => void,
|
value?: User,
|
||||||
|
onSelected?: (user: { id: number, name: string }) => void,
|
||||||
noHeader?: boolean
|
noHeader?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,22 +20,31 @@ type selectOption = { value: number, label: string };
|
|||||||
/**
|
/**
|
||||||
* This component renders the member select for manager.
|
* This component renders the member select for manager.
|
||||||
*/
|
*/
|
||||||
export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, onSelected, noHeader }) => {
|
export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, value, onSelected, noHeader }) => {
|
||||||
const { t } = useTranslation('public');
|
const { t } = useTranslation('public');
|
||||||
const [value, setValue] = useState<selectOption>();
|
const [option, setOption] = useState<selectOption>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (defaultUser) {
|
if (defaultUser) {
|
||||||
setValue({ value: defaultUser.id, label: defaultUser.name });
|
setOption({ value: defaultUser.id, label: defaultUser.name });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!defaultUser && value) {
|
if (!defaultUser && option) {
|
||||||
onSelected(value.value);
|
onSelected({ id: option.value, name: option.label });
|
||||||
}
|
}
|
||||||
}, [defaultUser]);
|
}, [defaultUser]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value && value?.id !== option?.value) {
|
||||||
|
setOption({ value: value.id, label: value.name });
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
setOption(null);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* search members by name
|
* search members by name
|
||||||
*/
|
*/
|
||||||
@ -52,8 +62,8 @@ export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, onSelec
|
|||||||
* callback for handle select changed
|
* callback for handle select changed
|
||||||
*/
|
*/
|
||||||
const onChange = (v: selectOption) => {
|
const onChange = (v: selectOption) => {
|
||||||
setValue(v);
|
setOption(v);
|
||||||
onSelected(v.value);
|
onSelected({ id: v.value, name: v.label });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -68,7 +78,7 @@ export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, onSelec
|
|||||||
loadOptions={loadMembers}
|
loadOptions={loadMembers}
|
||||||
defaultOptions
|
defaultOptions
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
value={value}
|
value={option}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -9,7 +9,7 @@ Application.Controllers.controller('AdminShowOrdersController', ['$rootScope', '
|
|||||||
/* PRIVATE SCOPE */
|
/* PRIVATE SCOPE */
|
||||||
|
|
||||||
/* PUBLIC SCOPE */
|
/* PUBLIC SCOPE */
|
||||||
$scope.orderToken = $transition$.params().token;
|
$scope.orderId = $transition$.params().id;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback triggered in case of error
|
* Callback triggered in case of error
|
||||||
|
@ -4,11 +4,14 @@
|
|||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
Application.Controllers.controller('AdminStoreProductController', ['$scope', 'CSRF', 'growl', '$state', '$transition$',
|
Application.Controllers.controller('AdminStoreProductController', ['$scope', 'CSRF', 'growl', '$state', '$transition$', '$uiRouter',
|
||||||
function ($scope, CSRF, growl, $state, $transition$) {
|
function ($scope, CSRF, growl, $state, $transition$, $uiRouter) {
|
||||||
/* PUBLIC SCOPE */
|
/* PUBLIC SCOPE */
|
||||||
$scope.productId = $transition$.params().id;
|
$scope.productId = $transition$.params().id;
|
||||||
|
|
||||||
|
// the following item is used by the UnsavedFormAlert component to detect a page change
|
||||||
|
$scope.uiRouter = $uiRouter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback triggered in case of error
|
* Callback triggered in case of error
|
||||||
*/
|
*/
|
||||||
|
@ -9,7 +9,7 @@ Application.Controllers.controller('ShowOrdersController', ['$rootScope', '$scop
|
|||||||
/* PRIVATE SCOPE */
|
/* PRIVATE SCOPE */
|
||||||
|
|
||||||
/* PUBLIC SCOPE */
|
/* PUBLIC SCOPE */
|
||||||
$scope.orderToken = $transition$.params().token;
|
$scope.orderId = $transition$.params().id;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback triggered in case of error
|
* Callback triggered in case of error
|
||||||
|
22
app/frontend/src/javascript/lib/deferred.ts
Normal file
22
app/frontend/src/javascript/lib/deferred.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// This is a kind of promise you can resolve from outside the function callback.
|
||||||
|
// Credits to https://stackoverflow.com/a/71158892/1039377
|
||||||
|
export default class Deferred<T> {
|
||||||
|
public readonly promise: Promise<T>;
|
||||||
|
private resolveFn!: (value: T | PromiseLike<T>) => void;
|
||||||
|
private rejectFn!: (reason?: unknown) => void;
|
||||||
|
|
||||||
|
public constructor () {
|
||||||
|
this.promise = new Promise<T>((resolve, reject) => {
|
||||||
|
this.resolveFn = resolve;
|
||||||
|
this.rejectFn = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public reject (reason?: unknown): void {
|
||||||
|
this.rejectFn(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public resolve (param: T): void {
|
||||||
|
this.resolveFn(param);
|
||||||
|
}
|
||||||
|
}
|
79
app/frontend/src/javascript/lib/order.ts
Normal file
79
app/frontend/src/javascript/lib/order.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { computePriceWithCoupon } from './coupon';
|
||||||
|
import { Order } from '../models/order';
|
||||||
|
|
||||||
|
export default class OrderLib {
|
||||||
|
/**
|
||||||
|
* Get the order item total
|
||||||
|
*/
|
||||||
|
static itemAmount = (item): number => {
|
||||||
|
return item.quantity * Math.trunc(item.amount * 100) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* return true if order has offered item
|
||||||
|
*/
|
||||||
|
static hasOfferedItem = (order: Order): boolean => {
|
||||||
|
return order.order_items_attributes
|
||||||
|
.filter(i => i.is_offered).length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the offered item total
|
||||||
|
*/
|
||||||
|
static offeredAmount = (order: Order): number => {
|
||||||
|
return order.order_items_attributes
|
||||||
|
.filter(i => i.is_offered)
|
||||||
|
.map(i => Math.trunc(i.amount * 100) * i.quantity)
|
||||||
|
.reduce((acc, curr) => acc + curr, 0) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the total amount before offered amount
|
||||||
|
*/
|
||||||
|
static totalBeforeOfferedAmount = (order: Order): number => {
|
||||||
|
return (Math.trunc(order.total * 100) + Math.trunc(this.offeredAmount(order) * 100)) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the coupon amount
|
||||||
|
*/
|
||||||
|
static couponAmount = (order: Order): number => {
|
||||||
|
return (Math.trunc(order.total * 100) - Math.trunc(computePriceWithCoupon(order.total, order.coupon) * 100)) / 100.00;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the paid total amount
|
||||||
|
*/
|
||||||
|
static paidTotal = (order: Order): number => {
|
||||||
|
return computePriceWithCoupon(order.total, order.coupon);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a className according to the status
|
||||||
|
*/
|
||||||
|
static statusColor = (order: Order) => {
|
||||||
|
switch (order.state) {
|
||||||
|
case 'cart':
|
||||||
|
return 'cart';
|
||||||
|
case 'paid':
|
||||||
|
return 'paid';
|
||||||
|
case 'payment_failed':
|
||||||
|
return 'error';
|
||||||
|
case 'ready':
|
||||||
|
return 'ready';
|
||||||
|
case 'canceled':
|
||||||
|
return 'canceled';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'pending';
|
||||||
|
default:
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a status text according to the status
|
||||||
|
*/
|
||||||
|
static statusText = (order: Order) => {
|
||||||
|
return order.state;
|
||||||
|
};
|
||||||
|
}
|
@ -1,11 +1,12 @@
|
|||||||
import { ProductCategory } from '../models/product-category';
|
import { ProductCategory } from '../models/product-category';
|
||||||
|
import { stockMovementInReasons, stockMovementOutReasons, StockMovementReason } from '../models/product';
|
||||||
|
|
||||||
export default class ProductLib {
|
export default class ProductLib {
|
||||||
/**
|
/**
|
||||||
* Map product categories by position
|
* Map product categories by position
|
||||||
* @param categories unsorted categories, as returned by the API
|
* @param categories unsorted categories, as returned by the API
|
||||||
*/
|
*/
|
||||||
sortCategories = (categories: Array<ProductCategory>): Array<ProductCategory> => {
|
static sortCategories = (categories: Array<ProductCategory>): Array<ProductCategory> => {
|
||||||
const sortedCategories = categories
|
const sortedCategories = categories
|
||||||
.filter(c => !c.parent_id)
|
.filter(c => !c.parent_id)
|
||||||
.sort((a, b) => a.position - b.position);
|
.sort((a, b) => a.position - b.position);
|
||||||
@ -18,4 +19,33 @@ export default class ProductLib {
|
|||||||
});
|
});
|
||||||
return sortedCategories;
|
return sortedCategories;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the translation key associated with the given reason
|
||||||
|
*/
|
||||||
|
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 +), if needed
|
||||||
|
*/
|
||||||
|
static absoluteStockMovement = (quantity: number, reason: StockMovementReason): string => {
|
||||||
|
if (ProductLib.stockMovementType(reason) === 'in') {
|
||||||
|
return `+${quantity}`;
|
||||||
|
} else {
|
||||||
|
if (quantity < 0) return quantity.toString();
|
||||||
|
return `-${quantity}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -16,16 +16,24 @@ export interface Order {
|
|||||||
operator_profile_id?: number,
|
operator_profile_id?: number,
|
||||||
reference?: string,
|
reference?: string,
|
||||||
state?: string,
|
state?: string,
|
||||||
payment_state?: string,
|
|
||||||
total?: number,
|
total?: number,
|
||||||
coupon?: Coupon,
|
coupon?: Coupon,
|
||||||
created_at?: TDateISO,
|
created_at?: TDateISO,
|
||||||
|
updated_at?: TDateISO,
|
||||||
|
invoice_id?: number,
|
||||||
|
payment_method?: string,
|
||||||
|
payment_date?: TDateISO,
|
||||||
|
wallet_amount?: number,
|
||||||
|
paid_total?: number,
|
||||||
order_items_attributes: Array<{
|
order_items_attributes: Array<{
|
||||||
id: number,
|
id: number,
|
||||||
orderable_type: string,
|
orderable_type: string,
|
||||||
orderable_id: number,
|
orderable_id: number,
|
||||||
orderable_name: string,
|
orderable_name: string,
|
||||||
|
orderable_ref?: string,
|
||||||
|
orderable_main_image_url?: string,
|
||||||
quantity: number,
|
quantity: number,
|
||||||
|
quantity_min: number,
|
||||||
amount: number,
|
amount: number,
|
||||||
is_offered: boolean
|
is_offered: boolean
|
||||||
}>,
|
}>,
|
||||||
@ -35,3 +43,25 @@ export interface OrderPayment {
|
|||||||
order: Order,
|
order: Order,
|
||||||
payment?: PaymentConfirmation|CreateTokenResponse
|
payment?: PaymentConfirmation|CreateTokenResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrderIndex {
|
||||||
|
page: number,
|
||||||
|
total_pages: number,
|
||||||
|
page_size: number,
|
||||||
|
total_count: number,
|
||||||
|
data: Array<Order>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderIndexFilter {
|
||||||
|
reference?: string,
|
||||||
|
user_id?: number,
|
||||||
|
user?: {
|
||||||
|
id: number,
|
||||||
|
name?: string,
|
||||||
|
},
|
||||||
|
page?: number,
|
||||||
|
sort?: 'DESC'|'ASC'
|
||||||
|
states?: Array<string>,
|
||||||
|
period_from?: string,
|
||||||
|
period_to?: string
|
||||||
|
}
|
||||||
|
@ -6,10 +6,13 @@ export interface ProductIndexFilter extends ApiFilter {
|
|||||||
page?: number
|
page?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum StockType {
|
export type StockType = 'internal' | 'external' | 'all';
|
||||||
internal = 'internal',
|
|
||||||
external = 'external'
|
export const stockMovementInReasons = ['inward_stock', 'returned', 'cancelled', 'inventory_fix', 'other_in'] as const;
|
||||||
}
|
export const stockMovementOutReasons = ['sold', 'missing', 'damaged', 'other_out'] as const;
|
||||||
|
export const stockMovementAllReasons = [...stockMovementInReasons, ...stockMovementOutReasons] as const;
|
||||||
|
|
||||||
|
export type StockMovementReason = typeof stockMovementAllReasons[number];
|
||||||
|
|
||||||
export interface Stock {
|
export interface Stock {
|
||||||
internal: number,
|
internal: number,
|
||||||
@ -21,6 +24,16 @@ export interface ProductsIndex {
|
|||||||
products: Array<Product>
|
products: Array<Product>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProductStockMovement {
|
||||||
|
id?: number,
|
||||||
|
product_id?: number,
|
||||||
|
quantity?: number,
|
||||||
|
reason?: StockMovementReason,
|
||||||
|
stock_type?: StockType,
|
||||||
|
remaining_stock?: number,
|
||||||
|
date?: TDateISO
|
||||||
|
}
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id?: number,
|
id?: number,
|
||||||
name: string,
|
name: string,
|
||||||
@ -35,6 +48,7 @@ export interface Product {
|
|||||||
low_stock_alert: boolean,
|
low_stock_alert: boolean,
|
||||||
low_stock_threshold?: number,
|
low_stock_threshold?: number,
|
||||||
machine_ids: number[],
|
machine_ids: number[],
|
||||||
|
created_at?: TDateISO,
|
||||||
product_files_attributes: Array<{
|
product_files_attributes: Array<{
|
||||||
id?: number,
|
id?: number,
|
||||||
attachment?: File,
|
attachment?: File,
|
||||||
@ -52,13 +66,5 @@ export interface Product {
|
|||||||
_destroy?: boolean,
|
_destroy?: boolean,
|
||||||
is_main?: boolean
|
is_main?: boolean
|
||||||
}>,
|
}>,
|
||||||
product_stock_movements_attributes: Array<{
|
product_stock_movements_attributes?: Array<ProductStockMovement>,
|
||||||
id?: number,
|
|
||||||
quantity?: number,
|
|
||||||
reason?: string,
|
|
||||||
stock_type?: string,
|
|
||||||
remaining_stock?: number,
|
|
||||||
date?: TDateISO,
|
|
||||||
_destroy?: boolean
|
|
||||||
}>,
|
|
||||||
}
|
}
|
||||||
|
@ -7,20 +7,20 @@ export const homePageSettings = [
|
|||||||
'home_content',
|
'home_content',
|
||||||
'home_css',
|
'home_css',
|
||||||
'upcoming_events_shown'
|
'upcoming_events_shown'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const privacyPolicySettings = [
|
export const privacyPolicySettings = [
|
||||||
'privacy_draft',
|
'privacy_draft',
|
||||||
'privacy_body',
|
'privacy_body',
|
||||||
'privacy_dpo'
|
'privacy_dpo'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const aboutPageSettings = [
|
export const aboutPageSettings = [
|
||||||
'about_title',
|
'about_title',
|
||||||
'about_body',
|
'about_body',
|
||||||
'about_contacts',
|
'about_contacts',
|
||||||
'link_name'
|
'link_name'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const socialNetworksSettings = [
|
export const socialNetworksSettings = [
|
||||||
'facebook',
|
'facebook',
|
||||||
@ -36,7 +36,7 @@ export const socialNetworksSettings = [
|
|||||||
'pinterest',
|
'pinterest',
|
||||||
'lastfm',
|
'lastfm',
|
||||||
'flickr'
|
'flickr'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const messagesSettings = [
|
export const messagesSettings = [
|
||||||
'machine_explications_alert',
|
'machine_explications_alert',
|
||||||
@ -45,7 +45,7 @@ export const messagesSettings = [
|
|||||||
'subscription_explications_alert',
|
'subscription_explications_alert',
|
||||||
'event_explications_alert',
|
'event_explications_alert',
|
||||||
'space_explications_alert'
|
'space_explications_alert'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const invoicesSettings = [
|
export const invoicesSettings = [
|
||||||
'invoice_logo',
|
'invoice_logo',
|
||||||
@ -65,7 +65,7 @@ export const invoicesSettings = [
|
|||||||
'invoice_legals',
|
'invoice_legals',
|
||||||
'invoice_prefix',
|
'invoice_prefix',
|
||||||
'payment_schedule_prefix'
|
'payment_schedule_prefix'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const bookingSettings = [
|
export const bookingSettings = [
|
||||||
'booking_window_start',
|
'booking_window_start',
|
||||||
@ -82,17 +82,17 @@ export const bookingSettings = [
|
|||||||
'book_overlapping_slots',
|
'book_overlapping_slots',
|
||||||
'slot_duration',
|
'slot_duration',
|
||||||
'overlapping_categories'
|
'overlapping_categories'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const themeSettings = [
|
export const themeSettings = [
|
||||||
'main_color',
|
'main_color',
|
||||||
'secondary_color'
|
'secondary_color'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const titleSettings = [
|
export const titleSettings = [
|
||||||
'fablab_name',
|
'fablab_name',
|
||||||
'name_genre'
|
'name_genre'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const accountingSettings = [
|
export const accountingSettings = [
|
||||||
'accounting_journal_code',
|
'accounting_journal_code',
|
||||||
@ -118,7 +118,7 @@ export const accountingSettings = [
|
|||||||
'accounting_Space_label',
|
'accounting_Space_label',
|
||||||
'accounting_Product_code',
|
'accounting_Product_code',
|
||||||
'accounting_Product_label'
|
'accounting_Product_label'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const modulesSettings = [
|
export const modulesSettings = [
|
||||||
'spaces_module',
|
'spaces_module',
|
||||||
@ -130,13 +130,13 @@ export const modulesSettings = [
|
|||||||
'online_payment_module',
|
'online_payment_module',
|
||||||
'public_agenda_module',
|
'public_agenda_module',
|
||||||
'invoicing_module'
|
'invoicing_module'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const stripeSettings = [
|
export const stripeSettings = [
|
||||||
'stripe_public_key',
|
'stripe_public_key',
|
||||||
'stripe_secret_key',
|
'stripe_secret_key',
|
||||||
'stripe_currency'
|
'stripe_currency'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const payzenSettings = [
|
export const payzenSettings = [
|
||||||
'payzen_username',
|
'payzen_username',
|
||||||
@ -145,13 +145,13 @@ export const payzenSettings = [
|
|||||||
'payzen_public_key',
|
'payzen_public_key',
|
||||||
'payzen_hmac',
|
'payzen_hmac',
|
||||||
'payzen_currency'
|
'payzen_currency'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const openLabSettings = [
|
export const openLabSettings = [
|
||||||
'openlab_app_id',
|
'openlab_app_id',
|
||||||
'openlab_app_secret',
|
'openlab_app_secret',
|
||||||
'openlab_default'
|
'openlab_default'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const accountSettings = [
|
export const accountSettings = [
|
||||||
'phone_required',
|
'phone_required',
|
||||||
@ -160,13 +160,13 @@ export const accountSettings = [
|
|||||||
'user_change_group',
|
'user_change_group',
|
||||||
'user_validation_required',
|
'user_validation_required',
|
||||||
'user_validation_required_list'
|
'user_validation_required_list'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const analyticsSettings = [
|
export const analyticsSettings = [
|
||||||
'tracking_id',
|
'tracking_id',
|
||||||
'facebook_app_id',
|
'facebook_app_id',
|
||||||
'twitter_analytics'
|
'twitter_analytics'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const fabHubSettings = [
|
export const fabHubSettings = [
|
||||||
'hub_last_version',
|
'hub_last_version',
|
||||||
@ -174,43 +174,43 @@ export const fabHubSettings = [
|
|||||||
'fab_analytics',
|
'fab_analytics',
|
||||||
'origin',
|
'origin',
|
||||||
'uuid'
|
'uuid'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const projectsSettings = [
|
export const projectsSettings = [
|
||||||
'allowed_cad_extensions',
|
'allowed_cad_extensions',
|
||||||
'allowed_cad_mime_types',
|
'allowed_cad_mime_types',
|
||||||
'disqus_shortname'
|
'disqus_shortname'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const prepaidPacksSettings = [
|
export const prepaidPacksSettings = [
|
||||||
'renew_pack_threshold',
|
'renew_pack_threshold',
|
||||||
'pack_only_for_subscription'
|
'pack_only_for_subscription'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const registrationSettings = [
|
export const registrationSettings = [
|
||||||
'public_registrations',
|
'public_registrations',
|
||||||
'recaptcha_site_key',
|
'recaptcha_site_key',
|
||||||
'recaptcha_secret_key'
|
'recaptcha_secret_key'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const adminSettings = [
|
export const adminSettings = [
|
||||||
'feature_tour_display',
|
'feature_tour_display',
|
||||||
'show_username_in_admin_list'
|
'show_username_in_admin_list'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const pricingSettings = [
|
export const pricingSettings = [
|
||||||
'extended_prices_in_same_day'
|
'extended_prices_in_same_day'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const poymentSettings = [
|
export const poymentSettings = [
|
||||||
'payment_gateway'
|
'payment_gateway'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const displaySettings = [
|
export const displaySettings = [
|
||||||
'machines_sort_by',
|
'machines_sort_by',
|
||||||
'events_in_calendar',
|
'events_in_calendar',
|
||||||
'email_from'
|
'email_from'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const allSettings = [
|
export const allSettings = [
|
||||||
...homePageSettings,
|
...homePageSettings,
|
||||||
|
@ -236,6 +236,15 @@ angular.module('application.router', ['ui.router'])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.state('app.logged.dashboard.order_show', {
|
||||||
|
url: '/orders/:id',
|
||||||
|
views: {
|
||||||
|
'main@': {
|
||||||
|
templateUrl: '/orders/show.html',
|
||||||
|
controller: 'ShowOrdersController'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.state('app.logged.dashboard.wallet', {
|
.state('app.logged.dashboard.wallet', {
|
||||||
url: '/wallet',
|
url: '/wallet',
|
||||||
abstract: !Fablab.walletModule,
|
abstract: !Fablab.walletModule,
|
||||||
@ -631,17 +640,6 @@ angular.module('application.router', ['ui.router'])
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// show order
|
|
||||||
.state('app.public.order_show', {
|
|
||||||
url: '/store/o/:token',
|
|
||||||
views: {
|
|
||||||
'main@': {
|
|
||||||
templateUrl: '/orders/show.html',
|
|
||||||
controller: 'ShowOrdersController'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// cart
|
// cart
|
||||||
.state('app.public.store_cart', {
|
.state('app.public.store_cart', {
|
||||||
url: '/store/cart',
|
url: '/store/cart',
|
||||||
@ -926,7 +924,7 @@ angular.module('application.router', ['ui.router'])
|
|||||||
|
|
||||||
// show order
|
// show order
|
||||||
.state('app.admin.order_show', {
|
.state('app.admin.order_show', {
|
||||||
url: '/admin/store/o/:token',
|
url: '/admin/store/orders/:id',
|
||||||
views: {
|
views: {
|
||||||
'main@': {
|
'main@': {
|
||||||
templateUrl: '/admin/orders/show.html',
|
templateUrl: '/admin/orders/show.html',
|
||||||
|
@ -17,6 +17,9 @@
|
|||||||
}
|
}
|
||||||
.fab-state-label {
|
.fab-state-label {
|
||||||
--status-color: var(--success);
|
--status-color: var(--success);
|
||||||
|
&.cart { --status-color: var(--secondary-dark); }
|
||||||
|
&.paid { --status-color: var(--success-light); }
|
||||||
|
&.ready { --status-color: var(--success); }
|
||||||
&.error { --status-color: var(--alert); }
|
&.error { --status-color: var(--alert); }
|
||||||
&.canceled { --status-color: var(--alert-light); }
|
&.canceled { --status-color: var(--alert-light); }
|
||||||
&.pending { --status-color: var(--information); }
|
&.pending { --status-color: var(--information); }
|
||||||
@ -45,4 +48,4 @@
|
|||||||
}
|
}
|
||||||
p { @include text-base(600); }
|
p { @include text-base(600); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.show-order {
|
.show-order {
|
||||||
&-nav {
|
&-nav {
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@include grid-col(12);
|
@include grid-col(12);
|
||||||
@ -102,9 +102,12 @@
|
|||||||
|
|
||||||
.fab-state-label {
|
.fab-state-label {
|
||||||
--status-color: var(--success);
|
--status-color: var(--success);
|
||||||
|
&.cart { --status-color: var(--secondary-dark); }
|
||||||
|
&.paid { --status-color: var(--success-light); }
|
||||||
|
&.ready { --status-color: var(--success); }
|
||||||
&.error { --status-color: var(--alert); }
|
&.error { --status-color: var(--alert); }
|
||||||
&.canceled { --status-color: var(--alert-light); }
|
&.canceled { --status-color: var(--alert-light); }
|
||||||
&.pending { --status-color: var(--information); }
|
&.pending { --status-color: var(--information); }
|
||||||
&.normal { --status-color: var(--success); }
|
&.normal { --status-color: var(--success); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,31 @@
|
|||||||
.product-stock-form {
|
.product-stock-form {
|
||||||
h4 span { @include text-sm; }
|
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 {
|
.store-list {
|
||||||
h4 { margin: 0; }
|
h4 { margin: 0; }
|
||||||
}
|
}
|
||||||
|
@ -15,5 +15,5 @@
|
|||||||
<span translate>{{ 'app.admin.store.back_to_list' }}</span>
|
<span translate>{{ 'app.admin.store.back_to_list' }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<show-order current-user="currentUser" order-ref="orderRef" on-error="onError" on-success="onSuccess" />
|
<show-order current-user="currentUser" order-id="orderId" on-error="onError" on-success="onSuccess" />
|
||||||
</section>
|
</section>
|
||||||
|
@ -15,5 +15,5 @@
|
|||||||
<span translate>{{ 'app.admin.store.back_to_list' }}</span>
|
<span translate>{{ 'app.admin.store.back_to_list' }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<edit-product product-id="productId" on-success="onSuccess" on-error="onError"/>
|
<edit-product product-id="productId" on-success="onSuccess" on-error="onError" ui-router="uiRouter" />
|
||||||
</section>
|
</section>
|
||||||
|
@ -15,5 +15,5 @@
|
|||||||
<span translate>{{ 'app.admin.store.back_to_list' }}</span>
|
<span translate>{{ 'app.admin.store.back_to_list' }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<new-product on-success="onSuccess" on-error="onError"/>
|
<new-product on-success="onSuccess" on-error="onError" ui-router="uiRouter"/>
|
||||||
</section>
|
</section>
|
||||||
|
@ -5,5 +5,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<orders-dashboard on-error="onError" />
|
<orders-dashboard current-user="currentUser" on-error="onError" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -12,6 +12,6 @@
|
|||||||
<span translate>{{ 'app.shared.store.show_order.back_to_list' }}</span>
|
<span translate>{{ 'app.shared.store.show_order.back_to_list' }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<show-order current-user="currentUser" order-token="orderToken" on-error="onError" on-success="onSuccess" />
|
<show-order current-user="currentUser" order-id="orderId" on-error="onError" on-success="onSuccess" />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
@ -69,6 +69,8 @@ class NotificationType
|
|||||||
notify_user_is_invalidated
|
notify_user_is_invalidated
|
||||||
notify_user_proof_of_identity_refusal
|
notify_user_proof_of_identity_refusal
|
||||||
notify_admin_user_proof_of_identity_refusal
|
notify_admin_user_proof_of_identity_refusal
|
||||||
|
notify_user_order_is_ready
|
||||||
|
notify_user_order_is_canceled
|
||||||
]
|
]
|
||||||
# deprecated:
|
# deprecated:
|
||||||
# - notify_member_subscribed_plan_is_changed
|
# - notify_member_subscribed_plan_is_changed
|
||||||
|
@ -8,13 +8,11 @@ class Order < PaymentDocument
|
|||||||
belongs_to :invoice
|
belongs_to :invoice
|
||||||
has_many :order_items, dependent: :destroy
|
has_many :order_items, dependent: :destroy
|
||||||
has_one :payment_gateway_object, as: :item
|
has_one :payment_gateway_object, as: :item
|
||||||
|
has_many :order_activities, dependent: :destroy
|
||||||
|
|
||||||
ALL_STATES = %w[cart in_progress ready canceled return].freeze
|
ALL_STATES = %w[cart paid payment_failed refunded in_progress ready canceled return].freeze
|
||||||
enum state: ALL_STATES.zip(ALL_STATES).to_h
|
enum state: ALL_STATES.zip(ALL_STATES).to_h
|
||||||
|
|
||||||
PAYMENT_STATES = %w[paid failed refunded].freeze
|
|
||||||
enum payment_state: PAYMENT_STATES.zip(PAYMENT_STATES).to_h
|
|
||||||
|
|
||||||
validates :token, :state, presence: true
|
validates :token, :state, presence: true
|
||||||
|
|
||||||
before_create :add_environment
|
before_create :add_environment
|
||||||
|
11
app/models/order_activity.rb
Normal file
11
app/models/order_activity.rb
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# OrderActivity is a model for hold activity of order
|
||||||
|
class OrderActivity < ApplicationRecord
|
||||||
|
belongs_to :order
|
||||||
|
|
||||||
|
TYPES = %w[paid payment_failed refunded in_progress ready canceled return note].freeze
|
||||||
|
enum activity_type: TYPES.zip(TYPES).to_h
|
||||||
|
|
||||||
|
validates :activity_type, presence: true
|
||||||
|
end
|
@ -23,4 +23,8 @@ class Product < ApplicationRecord
|
|||||||
validates :amount, numericality: { greater_than: 0, allow_nil: true }
|
validates :amount, numericality: { greater_than: 0, allow_nil: true }
|
||||||
|
|
||||||
scope :active, -> { where(is_active: true) }
|
scope :active, -> { where(is_active: true) }
|
||||||
|
|
||||||
|
def main_image
|
||||||
|
product_images.find_by(is_main: true)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -8,7 +8,9 @@ class ProductStockMovement < ApplicationRecord
|
|||||||
ALL_STOCK_TYPES = %w[internal external].freeze
|
ALL_STOCK_TYPES = %w[internal external].freeze
|
||||||
enum stock_type: ALL_STOCK_TYPES.zip(ALL_STOCK_TYPES).to_h
|
enum stock_type: ALL_STOCK_TYPES.zip(ALL_STOCK_TYPES).to_h
|
||||||
|
|
||||||
ALL_REASONS = %w[incoming_stock returned_by_customer cancelled_by_customer sold missing_from_inventory damaged].freeze
|
INCOMING_REASONS = %w[inward_stock returned cancelled inventory_fix other_in].freeze
|
||||||
|
OUTGOING_REASONS = %w[sold missing damaged other_out].freeze
|
||||||
|
ALL_REASONS = [].concat(INCOMING_REASONS).concat(OUTGOING_REASONS).freeze
|
||||||
enum reason: ALL_REASONS.zip(ALL_REASONS).to_h
|
enum reason: ALL_REASONS.zip(ALL_REASONS).to_h
|
||||||
|
|
||||||
validates :stock_type, presence: true
|
validates :stock_type, presence: true
|
||||||
|
16
app/policies/order_policy.rb
Normal file
16
app/policies/order_policy.rb
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Check the access policies for API::OrdersController
|
||||||
|
class OrderPolicy < ApplicationPolicy
|
||||||
|
def show?
|
||||||
|
user.privileged? || (record.statistic_profile_id == user.statistic_profile.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update?
|
||||||
|
user.privileged?
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy?
|
||||||
|
user.privileged?
|
||||||
|
end
|
||||||
|
end
|
@ -13,4 +13,8 @@ class ProductPolicy < ApplicationPolicy
|
|||||||
def destroy?
|
def destroy?
|
||||||
user.privileged?
|
user.privileged?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def stock_movements?
|
||||||
|
user.privileged?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -7,9 +7,11 @@ class Cart::AddItemService
|
|||||||
|
|
||||||
raise Cart::InactiveProductError unless orderable.is_active
|
raise Cart::InactiveProductError unless orderable.is_active
|
||||||
|
|
||||||
|
item = order.order_items.find_by(orderable: orderable)
|
||||||
|
quantity = orderable.quantity_min > quantity.to_i && item.nil? ? orderable.quantity_min : quantity.to_i
|
||||||
|
|
||||||
raise Cart::OutStockError if quantity > orderable.stock['external']
|
raise Cart::OutStockError if quantity > orderable.stock['external']
|
||||||
|
|
||||||
item = order.order_items.find_by(orderable: orderable)
|
|
||||||
if item.nil?
|
if item.nil?
|
||||||
item = order.order_items.new(quantity: quantity, orderable: orderable, amount: orderable.amount)
|
item = order.order_items.new(quantity: quantity, orderable: orderable, amount: orderable.amount)
|
||||||
else
|
else
|
||||||
|
@ -47,11 +47,11 @@ class Cart::FindOrCreateService
|
|||||||
end
|
end
|
||||||
@order = nil if @order && !@user && (@order.statistic_profile_id.present? || @order.operator_profile_id.present?)
|
@order = nil if @order && !@user && (@order.statistic_profile_id.present? || @order.operator_profile_id.present?)
|
||||||
if @order && @order.statistic_profile_id.present? && Order.where(statistic_profile_id: @order.statistic_profile_id,
|
if @order && @order.statistic_profile_id.present? && Order.where(statistic_profile_id: @order.statistic_profile_id,
|
||||||
payment_state: 'paid').where('created_at > ?', @order.created_at).last.present?
|
state: 'paid').where('created_at > ?', @order.created_at).last.present?
|
||||||
@order = nil
|
@order = nil
|
||||||
end
|
end
|
||||||
if @order && @order.operator_profile_id.present? && Order.where(operator_profile_id: @order.operator_profile_id,
|
if @order && @order.operator_profile_id.present? && Order.where(operator_profile_id: @order.operator_profile_id,
|
||||||
payment_state: 'paid').where('created_at > ?', @order.created_at).last.present?
|
state: 'paid').where('created_at > ?', @order.created_at).last.present?
|
||||||
@order = nil
|
@order = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -60,7 +60,7 @@ class Cart::FindOrCreateService
|
|||||||
def set_last_cart_if_user_login
|
def set_last_cart_if_user_login
|
||||||
if @user&.member?
|
if @user&.member?
|
||||||
last_paid_order = Order.where(statistic_profile_id: @user.statistic_profile.id,
|
last_paid_order = Order.where(statistic_profile_id: @user.statistic_profile.id,
|
||||||
payment_state: 'paid').last
|
state: 'paid').last
|
||||||
@order = if last_paid_order
|
@order = if last_paid_order
|
||||||
Order.where(statistic_profile_id: @user.statistic_profile.id,
|
Order.where(statistic_profile_id: @user.statistic_profile.id,
|
||||||
state: 'cart').where('created_at > ?', last_paid_order.created_at).last
|
state: 'cart').where('created_at > ?', last_paid_order.created_at).last
|
||||||
@ -70,7 +70,7 @@ class Cart::FindOrCreateService
|
|||||||
end
|
end
|
||||||
if @user&.privileged?
|
if @user&.privileged?
|
||||||
last_paid_order = Order.where(operator_profile_id: @user.invoicing_profile.id,
|
last_paid_order = Order.where(operator_profile_id: @user.invoicing_profile.id,
|
||||||
payment_state: 'paid').last
|
state: 'paid').last
|
||||||
@order = if last_paid_order
|
@order = if last_paid_order
|
||||||
Order.where(operator_profile_id: @user.invoicing_profile.id,
|
Order.where(operator_profile_id: @user.invoicing_profile.id,
|
||||||
state: 'cart').where('created_at > ?', last_paid_order.created_at).last
|
state: 'cart').where('created_at > ?', last_paid_order.created_at).last
|
||||||
@ -85,7 +85,7 @@ class Cart::FindOrCreateService
|
|||||||
last_unpaid_order = nil
|
last_unpaid_order = nil
|
||||||
if @user&.member?
|
if @user&.member?
|
||||||
last_paid_order = Order.where(statistic_profile_id: @user.statistic_profile.id,
|
last_paid_order = Order.where(statistic_profile_id: @user.statistic_profile.id,
|
||||||
payment_state: 'paid').last
|
state: 'paid').last
|
||||||
last_unpaid_order = if last_paid_order
|
last_unpaid_order = if last_paid_order
|
||||||
Order.where(statistic_profile_id: @user.statistic_profile.id,
|
Order.where(statistic_profile_id: @user.statistic_profile.id,
|
||||||
state: 'cart').where('created_at > ?', last_paid_order.created_at).last
|
state: 'cart').where('created_at > ?', last_paid_order.created_at).last
|
||||||
@ -95,7 +95,7 @@ class Cart::FindOrCreateService
|
|||||||
end
|
end
|
||||||
if @user&.privileged?
|
if @user&.privileged?
|
||||||
last_paid_order = Order.where(operator_profile_id: @user.invoicing_profile.id,
|
last_paid_order = Order.where(operator_profile_id: @user.invoicing_profile.id,
|
||||||
payment_state: 'paid').last
|
state: 'paid').last
|
||||||
last_unpaid_order = if last_paid_order
|
last_unpaid_order = if last_paid_order
|
||||||
Order.where(operator_profile_id: @user.invoicing_profile.id,
|
Order.where(operator_profile_id: @user.invoicing_profile.id,
|
||||||
state: 'cart').where('created_at > ?', last_paid_order.created_at).last
|
state: 'cart').where('created_at > ?', last_paid_order.created_at).last
|
||||||
|
@ -5,6 +5,8 @@ class Cart::SetQuantityService
|
|||||||
def call(order, orderable, quantity = nil)
|
def call(order, orderable, quantity = nil)
|
||||||
return order if quantity.to_i.zero?
|
return order if quantity.to_i.zero?
|
||||||
|
|
||||||
|
quantity = orderable.quantity_min > quantity.to_i ? orderable.quantity_min : quantity.to_i
|
||||||
|
|
||||||
raise Cart::OutStockError if quantity.to_i > orderable.stock['external']
|
raise Cart::OutStockError if quantity.to_i > orderable.stock['external']
|
||||||
|
|
||||||
item = order.order_items.find_by(orderable: orderable)
|
item = order.order_items.find_by(orderable: orderable)
|
||||||
|
@ -28,14 +28,14 @@ class Checkout::PaymentService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def confirm_payment(order, operator, coupon_code, payment_id = '')
|
def confirm_payment(order, operator, coupon_code, payment_id = '')
|
||||||
if operator.member?
|
return unless operator.member?
|
||||||
if Stripe::Helper.enabled?
|
|
||||||
Payments::StripeService.new.confirm_payment(order, coupon_code, payment_id)
|
if Stripe::Helper.enabled?
|
||||||
elsif PayZen::Helper.enabled?
|
Payments::StripeService.new.confirm_payment(order, coupon_code, payment_id)
|
||||||
Payments::PayzenService.new.confirm_payment(order, coupon_code, payment_id)
|
elsif PayZen::Helper.enabled?
|
||||||
else
|
Payments::PayzenService.new.confirm_payment(order, coupon_code, payment_id)
|
||||||
raise Error('Bad gateway or online payment is disabled')
|
else
|
||||||
end
|
raise Error('Bad gateway or online payment is disabled')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
21
app/services/orders/cancel_order_service.rb
Normal file
21
app/services/orders/cancel_order_service.rb
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Provides methods for cancel an order
|
||||||
|
class Orders::CancelOrderService
|
||||||
|
def call(order, current_user)
|
||||||
|
raise ::UpdateOrderStateError if %w[cart payment_failed canceled refunded].include?(order.state)
|
||||||
|
|
||||||
|
order.state = 'canceled'
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
activity = order.order_activities.create(activity_type: 'canceled', operator_profile_id: current_user.invoicing_profile.id)
|
||||||
|
order.order_items.each do |item|
|
||||||
|
ProductService.update_stock(item.orderable, 'external', 'cancelled', item.quantity, item.id)
|
||||||
|
end
|
||||||
|
order.save
|
||||||
|
NotificationCenter.call type: 'notify_user_order_is_canceled',
|
||||||
|
receiver: order.statistic_profile.user,
|
||||||
|
attached_object: activity
|
||||||
|
end
|
||||||
|
order.reload
|
||||||
|
end
|
||||||
|
end
|
18
app/services/orders/order_ready_service.rb
Normal file
18
app/services/orders/order_ready_service.rb
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Provides methods for set order to ready state
|
||||||
|
class Orders::OrderReadyService
|
||||||
|
def call(order, current_user, note = '')
|
||||||
|
raise ::UpdateOrderStateError if %w[cart payment_failed ready canceled refunded].include?(order.state)
|
||||||
|
|
||||||
|
order.state = 'ready'
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
activity = order.order_activities.create(activity_type: 'ready', operator_profile_id: current_user.invoicing_profile.id, note: note)
|
||||||
|
order.save
|
||||||
|
NotificationCenter.call type: 'notify_user_order_is_ready',
|
||||||
|
receiver: order.statistic_profile.user,
|
||||||
|
attached_object: activity
|
||||||
|
end
|
||||||
|
order.reload
|
||||||
|
end
|
||||||
|
end
|
@ -2,6 +2,53 @@
|
|||||||
|
|
||||||
# Provides methods for Order
|
# Provides methods for Order
|
||||||
class Orders::OrderService
|
class Orders::OrderService
|
||||||
|
ORDERS_PER_PAGE = 20
|
||||||
|
|
||||||
|
def self.list(filters, current_user)
|
||||||
|
orders = Order.where(nil)
|
||||||
|
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 = 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.order(created_at: filters[:sort] || 'DESC')
|
||||||
|
total_count = orders.count
|
||||||
|
orders = orders.page(filters[:page] || 1).per(ORDERS_PER_PAGE)
|
||||||
|
{
|
||||||
|
data: orders,
|
||||||
|
page: filters[:page] || 1,
|
||||||
|
total_pages: orders.page(1).per(ORDERS_PER_PAGE).total_pages,
|
||||||
|
page_size: ORDERS_PER_PAGE,
|
||||||
|
total_count: total_count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.update_state(order, current_user, state, note = nil)
|
||||||
|
return ::Orders::SetInProgressService.new.call(order, current_user) if state == 'in_progress'
|
||||||
|
return ::Orders::OrderReadyService.new.call(order, current_user, note) if state == 'ready'
|
||||||
|
return ::Orders::CancelOrderService.new.call(order, current_user) if state == 'canceled'
|
||||||
|
end
|
||||||
|
|
||||||
def in_stock?(order, stock_type = 'external')
|
def in_stock?(order, stock_type = 'external')
|
||||||
order.order_items.each do |item|
|
order.order_items.each do |item|
|
||||||
return false if item.orderable.stock[stock_type] < item.quantity
|
return false if item.orderable.stock[stock_type] < item.quantity
|
||||||
|
0
app/services/orders/refund_order_service.rb
Normal file
0
app/services/orders/refund_order_service.rb
Normal file
13
app/services/orders/set_in_progress_service.rb
Normal file
13
app/services/orders/set_in_progress_service.rb
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Provides methods for set in progress state to order
|
||||||
|
class Orders::SetInProgressService
|
||||||
|
def call(order, current_user)
|
||||||
|
raise ::UpdateOrderStateError if %w[cart payment_failed in_progress canceled refunded].include?(order.state)
|
||||||
|
|
||||||
|
order.state = 'in_progress'
|
||||||
|
order.order_activities.push(OrderActivity.new(activity_type: 'in_progress', operator_profile_id: current_user.invoicing_profile.id))
|
||||||
|
order.save
|
||||||
|
order.reload
|
||||||
|
end
|
||||||
|
end
|
@ -27,30 +27,33 @@ module Payments::PaymentConcern
|
|||||||
else
|
else
|
||||||
payment_method
|
payment_method
|
||||||
end
|
end
|
||||||
order.state = 'in_progress'
|
order.state = 'paid'
|
||||||
order.payment_state = 'paid'
|
|
||||||
if payment_id && payment_type
|
if payment_id && payment_type
|
||||||
order.payment_gateway_object = PaymentGatewayObject.new(gateway_object_id: payment_id, gateway_object_type: payment_type)
|
order.payment_gateway_object = PaymentGatewayObject.new(gateway_object_id: payment_id, gateway_object_type: payment_type)
|
||||||
end
|
end
|
||||||
|
order.order_activities.create(activity_type: 'paid')
|
||||||
order.order_items.each do |item|
|
order.order_items.each do |item|
|
||||||
ProductService.update_stock(item.orderable, 'external', 'sold', -item.quantity, item.id)
|
ProductService.update_stock(item.orderable,
|
||||||
end
|
[{ stock_type: 'external', reason: 'sold', quantity: item.quantity, order_item_id: item.id }]).save
|
||||||
if order.save
|
|
||||||
invoice = InvoicesService.create(
|
|
||||||
{ total: order.total, coupon: coupon },
|
|
||||||
order.operator_profile_id,
|
|
||||||
order.order_items,
|
|
||||||
order.statistic_profile.user,
|
|
||||||
payment_id: payment_id,
|
|
||||||
payment_type: payment_type,
|
|
||||||
payment_method: order.payment_method
|
|
||||||
)
|
|
||||||
invoice.wallet_amount = order.wallet_amount
|
|
||||||
invoice.wallet_transaction_id = order.wallet_transaction_id
|
|
||||||
invoice.save
|
|
||||||
order.update_attribute(:invoice_id, invoice.id)
|
|
||||||
end
|
end
|
||||||
|
create_invoice(order, coupon, payment_id, payment_type) if order.save
|
||||||
order.reload
|
order.reload
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_invoice(order, coupon, payment_id, payment_type)
|
||||||
|
invoice = InvoicesService.create(
|
||||||
|
{ total: order.total, coupon: coupon },
|
||||||
|
order.operator_profile_id,
|
||||||
|
order.order_items,
|
||||||
|
order.statistic_profile.user,
|
||||||
|
payment_id: payment_id,
|
||||||
|
payment_type: payment_type,
|
||||||
|
payment_method: order.payment_method
|
||||||
|
)
|
||||||
|
invoice.wallet_amount = order.wallet_amount
|
||||||
|
invoice.wallet_transaction_id = order.wallet_transaction_id
|
||||||
|
invoice.save
|
||||||
|
order.update(invoice_id: invoice.id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -31,7 +31,7 @@ class Payments::PayzenService
|
|||||||
o = payment_success(order, coupon_code, 'card', payment_id, 'PayZen::Order')
|
o = payment_success(order, coupon_code, 'card', payment_id, 'PayZen::Order')
|
||||||
{ order: o }
|
{ order: o }
|
||||||
else
|
else
|
||||||
order.update(payment_state: 'failed')
|
order.update(state: 'payment_failed')
|
||||||
{ order: order, payment: { error: { statusText: payzen_order['answer'] } } }
|
{ order: order, payment: { error: { statusText: payzen_order['answer'] } } }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -39,7 +39,7 @@ class Payments::StripeService
|
|||||||
o = payment_success(order, coupon_code, 'card', intent.id, intent.class.name)
|
o = payment_success(order, coupon_code, 'card', intent.id, intent.class.name)
|
||||||
{ order: o }
|
{ order: o }
|
||||||
else
|
else
|
||||||
order.update(payment_state: 'failed')
|
order.update(state: 'payment_failed')
|
||||||
{ order: order, payment: { error: { statusText: 'payment failed' } } }
|
{ order: order, payment: { error: { statusText: 'payment failed' } } }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -2,45 +2,67 @@
|
|||||||
|
|
||||||
# Provides methods for Product
|
# Provides methods for Product
|
||||||
class ProductService
|
class ProductService
|
||||||
PRODUCTS_PER_PAGE = 12
|
class << self
|
||||||
|
PRODUCTS_PER_PAGE = 12
|
||||||
|
|
||||||
def self.list(filters)
|
def list(filters)
|
||||||
products = Product.includes(:product_images)
|
products = Product.includes(:product_images)
|
||||||
if filters[:is_active].present?
|
if filters[:is_active].present?
|
||||||
state = filters[:disabled] == 'false' ? [nil, false] : true
|
state = filters[:disabled] == 'false' ? [nil, false] : true
|
||||||
products = products.where(is_active: state)
|
products = products.where(is_active: state)
|
||||||
|
end
|
||||||
|
products = products.page(filters[:page]).per(PRODUCTS_PER_PAGE) if filters[:page].present?
|
||||||
|
products
|
||||||
end
|
end
|
||||||
products = products.page(filters[:page]).per(PRODUCTS_PER_PAGE) if filters[:page].present?
|
|
||||||
products
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.pages(filters)
|
def pages(filters)
|
||||||
products = Product.all
|
products = Product.all
|
||||||
if filters[:is_active].present?
|
if filters[:is_active].present?
|
||||||
state = filters[:disabled] == 'false' ? [nil, false] : true
|
state = filters[:disabled] == 'false' ? [nil, false] : true
|
||||||
products = Product.where(is_active: state)
|
products = Product.where(is_active: state)
|
||||||
|
end
|
||||||
|
products.page(1).per(PRODUCTS_PER_PAGE).total_pages
|
||||||
end
|
end
|
||||||
products.page(1).per(PRODUCTS_PER_PAGE).total_pages
|
|
||||||
end
|
|
||||||
|
|
||||||
# amount params multiplied by hundred
|
# amount params multiplied by hundred
|
||||||
def self.amount_multiplied_by_hundred(amount)
|
def amount_multiplied_by_hundred(amount)
|
||||||
if amount.present?
|
if amount.present?
|
||||||
v = amount.to_f
|
v = amount.to_f
|
||||||
|
|
||||||
return nil if v.zero?
|
return nil if v.zero?
|
||||||
|
|
||||||
return v * 100
|
return v * 100
|
||||||
|
end
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.update_stock(product, stock_type, reason, quantity, order_item_id = nil)
|
# @param product Product
|
||||||
remaining_stock = product.stock[stock_type] + quantity
|
# @param stock_movements [{stock_type: string, reason: string, quantity: number|string, order_item_id: number|nil}]
|
||||||
product.product_stock_movements.create(stock_type: stock_type, reason: reason, quantity: quantity, remaining_stock: remaining_stock,
|
def update_stock(product, stock_movements = nil)
|
||||||
date: DateTime.current,
|
remaining_stock = { internal: product.stock['internal'], external: product.stock['external'] }
|
||||||
order_item_id: order_item_id)
|
product.product_stock_movements_attributes = stock_movements&.map do |movement|
|
||||||
product.stock[stock_type] = remaining_stock
|
quantity = ProductStockMovement::OUTGOING_REASONS.include?(movement[:reason]) ? -movement[:quantity].to_i : movement[:quantity].to_i
|
||||||
product.save
|
remaining_stock[movement[:stock_type].to_sym] += quantity
|
||||||
|
{
|
||||||
|
stock_type: movement[:stock_type], reason: movement[:reason], quantity: quantity,
|
||||||
|
remaining_stock: remaining_stock[movement[:stock_type].to_sym], date: DateTime.current, order_item_id: movement[:order_item_id]
|
||||||
|
}
|
||||||
|
end || {}
|
||||||
|
product.stock = remaining_stock
|
||||||
|
product
|
||||||
|
end
|
||||||
|
|
||||||
|
def create(product_params, stock_movement_params = [])
|
||||||
|
product = Product.new(product_params)
|
||||||
|
product.amount = amount_multiplied_by_hundred(product_params[:amount])
|
||||||
|
update(product, product_params, stock_movement_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update(product, product_params, stock_movement_params = [])
|
||||||
|
product_params[:amount] = amount_multiplied_by_hundred(product_params[:amount])
|
||||||
|
product.attributes = product_params
|
||||||
|
update_stock(product, stock_movement_params)
|
||||||
|
product
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -79,13 +79,13 @@ class WalletService
|
|||||||
# @param user {User} the customer
|
# @param user {User} the customer
|
||||||
# @param coupon {Coupon|String} Coupon object or code
|
# @param coupon {Coupon|String} Coupon object or code
|
||||||
##
|
##
|
||||||
def self.wallet_amount_debit(payment, user, coupon = nil)
|
def self.wallet_amount_debit(payment, user)
|
||||||
total = if payment.is_a? PaymentSchedule
|
total = if payment.is_a? PaymentSchedule
|
||||||
payment.payment_schedule_items.first.amount
|
payment.payment_schedule_items.first.amount
|
||||||
else
|
else
|
||||||
payment.total
|
payment.total
|
||||||
end
|
end
|
||||||
total = CouponService.new.apply(total, coupon, user.id) if coupon
|
total = CouponService.new.apply(total, payment.coupon, user.id) if payment.coupon
|
||||||
|
|
||||||
wallet_amount = (user.wallet.amount * 100).to_i
|
wallet_amount = (user.wallet.amount * 100).to_i
|
||||||
|
|
||||||
|
@ -0,0 +1,2 @@
|
|||||||
|
json.title notification.notification_type
|
||||||
|
json.description t('.order_canceled', REFERENCE: notification.attached_object.order.reference)
|
@ -0,0 +1,2 @@
|
|||||||
|
json.title notification.notification_type
|
||||||
|
json.description t('.order_ready', REFERENCE: notification.attached_object.order.reference)
|
@ -1,7 +1,17 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
json.extract! order, :id, :token, :statistic_profile_id, :operator_profile_id, :reference, :state, :created_at
|
json.extract! order, :id, :token, :statistic_profile_id, :operator_profile_id, :reference, :state, :created_at, :updated_at, :invoice_id,
|
||||||
|
:payment_method
|
||||||
json.total order.total / 100.0 if order.total.present?
|
json.total order.total / 100.0 if order.total.present?
|
||||||
|
json.payment_date order.invoice.created_at if order.invoice_id.present?
|
||||||
|
json.wallet_amount order.wallet_amount / 100.0 if order.wallet_amount.present?
|
||||||
|
json.paid_total order.paid_total / 100.0 if order.paid_total.present?
|
||||||
|
if order.coupon_id
|
||||||
|
json.coupon do
|
||||||
|
json.extract! order.coupon, :id, :code, :type, :percent_off, :validity_per_user
|
||||||
|
json.amount_off order.coupon.amount_off / 100.00 unless order.coupon.amount_off.nil?
|
||||||
|
end
|
||||||
|
end
|
||||||
if order&.statistic_profile&.user
|
if order&.statistic_profile&.user
|
||||||
json.user do
|
json.user do
|
||||||
json.id order.statistic_profile.user.id
|
json.id order.statistic_profile.user.id
|
||||||
@ -15,7 +25,10 @@ json.order_items_attributes order.order_items.order(created_at: :asc) do |item|
|
|||||||
json.orderable_type item.orderable_type
|
json.orderable_type item.orderable_type
|
||||||
json.orderable_id item.orderable_id
|
json.orderable_id item.orderable_id
|
||||||
json.orderable_name item.orderable.name
|
json.orderable_name item.orderable.name
|
||||||
|
json.orderable_ref item.orderable.sku
|
||||||
|
json.orderable_main_image_url item.orderable.main_image&.attachment_url
|
||||||
json.quantity item.quantity
|
json.quantity item.quantity
|
||||||
|
json.quantity_min item.orderable.quantity_min
|
||||||
json.amount item.amount / 100.0
|
json.amount item.amount / 100.0
|
||||||
json.is_offered item.is_offered
|
json.is_offered item.is_offered
|
||||||
end
|
end
|
||||||
|
18
app/views/api/orders/index.json.jbuilder
Normal file
18
app/views/api/orders/index.json.jbuilder
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
json.page @result[:page]
|
||||||
|
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.extract! order, :id, :statistic_profile_id, :reference, :state, :created_at, :updated_at
|
||||||
|
json.total order.total / 100.0 if order.total.present?
|
||||||
|
json.paid_total order.paid_total / 100.0 if order.paid_total.present?
|
||||||
|
if order&.statistic_profile&.user
|
||||||
|
json.user do
|
||||||
|
json.id order.statistic_profile.user.id
|
||||||
|
json.role order.statistic_profile.user.roles.first.name
|
||||||
|
json.name order.statistic_profile.user.profile.full_name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
3
app/views/api/orders/update.json.jbuilder
Normal file
3
app/views/api/orders/update.json.jbuilder
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
json.partial! 'api/orders/order', order: @order
|
@ -1,7 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert,
|
json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert,
|
||||||
:low_stock_threshold, :machine_ids
|
:low_stock_threshold, :machine_ids, :created_at
|
||||||
json.description sanitize(product.description)
|
json.description sanitize(product.description)
|
||||||
json.amount product.amount / 100.0 if product.amount.present?
|
json.amount product.amount / 100.0 if product.amount.present?
|
||||||
json.product_files_attributes product.product_files do |f|
|
json.product_files_attributes product.product_files do |f|
|
||||||
@ -15,11 +15,3 @@ json.product_images_attributes product.product_images do |f|
|
|||||||
json.attachment_url f.attachment_url
|
json.attachment_url f.attachment_url
|
||||||
json.is_main f.is_main
|
json.is_main f.is_main
|
||||||
end
|
end
|
||||||
json.product_stock_movements_attributes product.product_stock_movements do |s|
|
|
||||||
json.id s.id
|
|
||||||
json.quantity s.quantity
|
|
||||||
json.reason s.reason
|
|
||||||
json.stock_type s.stock_type
|
|
||||||
json.remaining_stock s.remaining_stock
|
|
||||||
json.date s.date
|
|
||||||
end
|
|
||||||
|
3
app/views/api/products/_stock_movement.json.jbuilder
Normal file
3
app/views/api/products/_stock_movement.json.jbuilder
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
json.extract! stock_movement, :id, :quantity, :reason, :stock_type, :remaining_stock, :date
|
6
app/views/api/products/stock_movements.json.jbuilder
Normal file
6
app/views/api/products/stock_movements.json.jbuilder
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
json.array! @movements do |movement|
|
||||||
|
json.partial! 'api/products/stock_movement', stock_movement: movement
|
||||||
|
json.extract! movement, :product_id
|
||||||
|
end
|
@ -0,0 +1,5 @@
|
|||||||
|
<%= render 'notifications_mailer/shared/hello', recipient: @recipient %>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<%= t('.body.notify_user_order_is_canceled', REFERENCE: @attached_object.order.reference) %>
|
||||||
|
</p>
|
@ -0,0 +1,8 @@
|
|||||||
|
<%= render 'notifications_mailer/shared/hello', recipient: @recipient %>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<%= t('.body.notify_user_order_is_ready', REFERENCE: @attached_object.order.reference) %>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<%= @attached_object.note %>
|
||||||
|
</p>
|
@ -1998,44 +1998,48 @@ en:
|
|||||||
add_product_image: "Add an image"
|
add_product_image: "Add an image"
|
||||||
save: "Save"
|
save: "Save"
|
||||||
product_stock_form:
|
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"
|
low_stock_threshold: "Define a low stock threshold"
|
||||||
stock_threshold_toggle: "Activate 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."
|
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"
|
low_stock: "Low stock"
|
||||||
threshold_level: "Minimum threshold level"
|
threshold_level: "Minimum threshold level"
|
||||||
threshold_alert: "Notify me when the threshold is reached"
|
threshold_alert: "Notify me when the threshold is reached"
|
||||||
|
events_history: "Events history"
|
||||||
event_type: "Events:"
|
event_type: "Events:"
|
||||||
|
reason: "Reason"
|
||||||
stocks: "Stocks:"
|
stocks: "Stocks:"
|
||||||
internal: "Private stock"
|
internal: "Private stock"
|
||||||
external: "Public stock"
|
external: "Public stock"
|
||||||
all: "All types"
|
all: "All types"
|
||||||
stock_level: "Stock level"
|
remaining_stock: "Remaining stock"
|
||||||
events:
|
type_in: "Add"
|
||||||
inward_stock: "Inward stock"
|
type_out: "Remove"
|
||||||
returned: "Returned by client"
|
cancel: "Cancel this operation"
|
||||||
canceled: "Canceled by client"
|
|
||||||
sold: "Sold"
|
|
||||||
missing: "Missing in stock"
|
|
||||||
damaged: "Damaged product"
|
|
||||||
events_history: "Events history"
|
|
||||||
modal_title: "Manage stock"
|
|
||||||
product_stock_modal:
|
product_stock_modal:
|
||||||
|
modal_title: "Manage stock"
|
||||||
internal: "Private stock"
|
internal: "Private stock"
|
||||||
external: "Public stock"
|
external: "Public stock"
|
||||||
new_event: "New stock event"
|
new_event: "New stock event"
|
||||||
addition: "Addition"
|
addition: "Addition"
|
||||||
withdrawal: "Withdrawal"
|
withdrawal: "Withdrawal"
|
||||||
update_stock: "Update stock"
|
update_stock: "Update stock"
|
||||||
event_type: "Events:"
|
reason_type: "Reason"
|
||||||
stocks: "Stocks:"
|
stocks: "Stocks:"
|
||||||
quantity: "Quantity"
|
quantity: "Quantity"
|
||||||
events:
|
stock_movement_reason:
|
||||||
inward_stock: "Inward stock"
|
inward_stock: "Inward stock"
|
||||||
returned: "Returned by client"
|
returned: "Returned by client"
|
||||||
canceled: "Canceled by client"
|
cancelled: "Canceled by client"
|
||||||
sold: "Sold"
|
inventory_fix: "Inventory fix"
|
||||||
missing: "Missing in stock"
|
sold: "Sold"
|
||||||
damaged: "Damaged product"
|
missing: "Missing in stock"
|
||||||
|
damaged: "Damaged product"
|
||||||
|
other_in: "Other"
|
||||||
|
other_out: "Other"
|
||||||
orders:
|
orders:
|
||||||
heading: "Orders"
|
heading: "Orders"
|
||||||
create_order: "Create an order"
|
create_order: "Create an order"
|
||||||
@ -2048,14 +2052,13 @@ en:
|
|||||||
filter_period: "By period"
|
filter_period: "By period"
|
||||||
filter_period_from: "From"
|
filter_period_from: "From"
|
||||||
filter_period_to: "to"
|
filter_period_to: "to"
|
||||||
status:
|
state:
|
||||||
error: "Payment error"
|
cart: 'Cart'
|
||||||
canceled: "Canceled"
|
in_progress: 'In progress'
|
||||||
pending: "Pending payment"
|
|
||||||
under_preparation: "Under preparation"
|
|
||||||
paid: "Paid"
|
paid: "Paid"
|
||||||
|
payment_failed: "Payment error"
|
||||||
|
canceled: "Canceled"
|
||||||
ready: "Ready"
|
ready: "Ready"
|
||||||
collected: "Collected"
|
|
||||||
refunded: "Refunded"
|
refunded: "Refunded"
|
||||||
sort:
|
sort:
|
||||||
newest: "Newest first"
|
newest: "Newest first"
|
||||||
|
@ -23,6 +23,7 @@ fr:
|
|||||||
my_invoices: "Mes factures"
|
my_invoices: "Mes factures"
|
||||||
my_payment_schedules: "Mes échéanciers"
|
my_payment_schedules: "Mes échéanciers"
|
||||||
my_wallet: "Mon porte-monnaie"
|
my_wallet: "Mon porte-monnaie"
|
||||||
|
my_orders: "Mes commandes"
|
||||||
#contextual help
|
#contextual help
|
||||||
help: "Aide"
|
help: "Aide"
|
||||||
#login/logout
|
#login/logout
|
||||||
|
@ -562,8 +562,16 @@ en:
|
|||||||
main_image: "Main image"
|
main_image: "Main image"
|
||||||
store:
|
store:
|
||||||
order_item:
|
order_item:
|
||||||
total: "Total"
|
total: "Total"
|
||||||
client: "Client"
|
client: "Client"
|
||||||
|
state:
|
||||||
|
cart: 'Cart'
|
||||||
|
in_progress: 'In progress'
|
||||||
|
paid: "Paid"
|
||||||
|
payment_failed: "Payment error"
|
||||||
|
canceled: "Canceled"
|
||||||
|
ready: "Ready"
|
||||||
|
refunded: "Refunded"
|
||||||
show_order:
|
show_order:
|
||||||
back_to_list: "Back to list"
|
back_to_list: "Back to list"
|
||||||
see_invoice: "See invoice"
|
see_invoice: "See invoice"
|
||||||
@ -581,12 +589,41 @@ en:
|
|||||||
gift_total: "Discount total"
|
gift_total: "Discount total"
|
||||||
coupon: "Coupon"
|
coupon: "Coupon"
|
||||||
cart_total: "Cart total"
|
cart_total: "Cart total"
|
||||||
status:
|
state:
|
||||||
error: "Payment error"
|
cart: 'Cart'
|
||||||
canceled: "Canceled"
|
in_progress: 'In progress'
|
||||||
pending: "Pending payment"
|
|
||||||
under_preparation: "Under preparation"
|
|
||||||
paid: "Paid"
|
paid: "Paid"
|
||||||
|
payment_failed: "Payment error"
|
||||||
|
canceled: "Canceled"
|
||||||
ready: "Ready"
|
ready: "Ready"
|
||||||
collected: "Collected"
|
|
||||||
refunded: "Refunded"
|
refunded: "Refunded"
|
||||||
|
payment:
|
||||||
|
by_wallet: "by wallet"
|
||||||
|
settlement_by_debit_card: "Settlement by debit card"
|
||||||
|
settlement_done_at_the_reception: "Settlement done at the reception"
|
||||||
|
settlement_by_wallet: "Settlement by wallet"
|
||||||
|
on_DATE_at_TIME: "on {DATE} at {TIME},"
|
||||||
|
for_an_amount_of_AMOUNT: "for an amount of {AMOUNT}"
|
||||||
|
and: 'and'
|
||||||
|
order_actions:
|
||||||
|
state:
|
||||||
|
cart: 'Cart'
|
||||||
|
in_progress: 'In progress'
|
||||||
|
paid: "Paid"
|
||||||
|
payment_failed: "Payment error"
|
||||||
|
canceled: "Canceled"
|
||||||
|
ready: "Ready"
|
||||||
|
refunded: "Refunded"
|
||||||
|
confirm: 'Confirm'
|
||||||
|
confirmation_required: "Confirmation required"
|
||||||
|
confirm_order_in_progress: "This order is in the process of being prepared ?"
|
||||||
|
order_in_progress_success: "Order is under preparation"
|
||||||
|
confirm_order_ready: "This order is ready ?"
|
||||||
|
order_ready_note: ''
|
||||||
|
order_ready_success: "Order is ready"
|
||||||
|
confirm_order_canceled: "Do you want to cancel this order ?"
|
||||||
|
order_canceled_success: "Order is canceled"
|
||||||
|
unsaved_form_alert:
|
||||||
|
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_button: "Yes, don't save"
|
||||||
|
@ -121,7 +121,7 @@ en:
|
|||||||
error_invoice: "Erroneous invoice. The items below ware not booked. Please contact the FabLab for a refund."
|
error_invoice: "Erroneous invoice. The items below ware not booked. Please contact the FabLab for a refund."
|
||||||
prepaid_pack: "Prepaid pack of hours"
|
prepaid_pack: "Prepaid pack of hours"
|
||||||
pack_item: "Pack of %{COUNT} hours for the %{ITEM}"
|
pack_item: "Pack of %{COUNT} hours for the %{ITEM}"
|
||||||
order: "Order of products"
|
order: "Your order of the store"
|
||||||
#PDF payment schedule generation
|
#PDF payment schedule generation
|
||||||
payment_schedules:
|
payment_schedules:
|
||||||
schedule_reference: "Payment schedule reference: %{REF}"
|
schedule_reference: "Payment schedule reference: %{REF}"
|
||||||
@ -407,6 +407,10 @@ en:
|
|||||||
refusal: "Your proof of identity are not accepted"
|
refusal: "Your proof of identity are not accepted"
|
||||||
notify_admin_user_proof_of_identity_refusal:
|
notify_admin_user_proof_of_identity_refusal:
|
||||||
refusal: "Member's proof of identity <strong><em>%{NAME}</strong></em> refused."
|
refusal: "Member's proof of identity <strong><em>%{NAME}</strong></em> refused."
|
||||||
|
notify_user_order_is_ready:
|
||||||
|
order_ready: "Your command %{REFERENCE} is ready"
|
||||||
|
notify_user_order_is_canceled:
|
||||||
|
order_canceled: "Your command %{REFERENCE} is canceled"
|
||||||
#statistics tools for admins
|
#statistics tools for admins
|
||||||
statistics:
|
statistics:
|
||||||
subscriptions: "Subscriptions"
|
subscriptions: "Subscriptions"
|
||||||
|
@ -121,7 +121,7 @@ fr:
|
|||||||
error_invoice: "Facture en erreur. Les éléments ci-dessous n'ont pas été réservés. Veuillez contacter le Fablab pour un remboursement."
|
error_invoice: "Facture en erreur. Les éléments ci-dessous n'ont pas été réservés. Veuillez contacter le Fablab pour un remboursement."
|
||||||
prepaid_pack: "Paquet d'heures prépayé"
|
prepaid_pack: "Paquet d'heures prépayé"
|
||||||
pack_item: "Pack de %{COUNT} heures pour la %{ITEM}"
|
pack_item: "Pack de %{COUNT} heures pour la %{ITEM}"
|
||||||
order: "La commande des produits"
|
order: "Votre commande de la boutique"
|
||||||
#PDF payment schedule generation
|
#PDF payment schedule generation
|
||||||
payment_schedules:
|
payment_schedules:
|
||||||
schedule_reference: "Référence de l'échéancier : %{REF}"
|
schedule_reference: "Référence de l'échéancier : %{REF}"
|
||||||
|
@ -374,3 +374,11 @@ en:
|
|||||||
user_proof_of_identity_files_refusal: "Member %{NAME}'s supporting documents were rejected by %{OPERATOR}:"
|
user_proof_of_identity_files_refusal: "Member %{NAME}'s supporting documents were rejected by %{OPERATOR}:"
|
||||||
shared:
|
shared:
|
||||||
hello: "Hello %{user_name}"
|
hello: "Hello %{user_name}"
|
||||||
|
notify_user_order_is_ready:
|
||||||
|
subject: "Your command is ready"
|
||||||
|
body:
|
||||||
|
notify_user_order_is_ready: "Your command %{REFERENCE} is ready:"
|
||||||
|
notify_user_order_is_canceled:
|
||||||
|
subject: "Your command is canceled"
|
||||||
|
body:
|
||||||
|
notify_user_order_is_canceled: "Your command %{REFERENCE} is canceled:"
|
||||||
|
@ -154,7 +154,9 @@ Rails.application.routes.draw do
|
|||||||
patch 'position', on: :member
|
patch 'position', on: :member
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :products
|
resources :products do
|
||||||
|
get 'stock_movements', on: :member
|
||||||
|
end
|
||||||
resources :cart, only: %i[create] do
|
resources :cart, only: %i[create] do
|
||||||
put 'add_item', on: :collection
|
put 'add_item', on: :collection
|
||||||
put 'remove_item', on: :collection
|
put 'remove_item', on: :collection
|
||||||
@ -165,6 +167,7 @@ Rails.application.routes.draw do
|
|||||||
post 'payment', on: :collection
|
post 'payment', on: :collection
|
||||||
post 'confirm_payment', on: :collection
|
post 'confirm_payment', on: :collection
|
||||||
end
|
end
|
||||||
|
resources :orders, except: %i[create]
|
||||||
|
|
||||||
# for admin
|
# for admin
|
||||||
resources :trainings do
|
resources :trainings do
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
class RemovePaymentStateFromOrders < ActiveRecord::Migration[5.2]
|
||||||
|
def change
|
||||||
|
remove_column :orders, :payment_state
|
||||||
|
end
|
||||||
|
end
|
12
db/migrate/20220915133100_create_order_activities.rb
Normal file
12
db/migrate/20220915133100_create_order_activities.rb
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
class CreateOrderActivities < ActiveRecord::Migration[5.2]
|
||||||
|
def change
|
||||||
|
create_table :order_activities do |t|
|
||||||
|
t.belongs_to :order, foreign_key: true
|
||||||
|
t.references :operator_profile, foreign_key: { to_table: 'invoicing_profiles' }
|
||||||
|
t.string :activity_type
|
||||||
|
t.text :note
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
16
db/schema.rb
16
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_09_131300) do
|
ActiveRecord::Schema.define(version: 2022_09_15_133100) 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"
|
||||||
@ -445,6 +445,17 @@ ActiveRecord::Schema.define(version: 2022_09_09_131300) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "order_activities", force: :cascade do |t|
|
||||||
|
t.bigint "order_id"
|
||||||
|
t.bigint "operator_profile_id"
|
||||||
|
t.string "activity_type"
|
||||||
|
t.text "note"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["operator_profile_id"], name: "index_order_activities_on_operator_profile_id"
|
||||||
|
t.index ["order_id"], name: "index_order_activities_on_order_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "order_items", force: :cascade do |t|
|
create_table "order_items", force: :cascade do |t|
|
||||||
t.bigint "order_id"
|
t.bigint "order_id"
|
||||||
t.string "orderable_type"
|
t.string "orderable_type"
|
||||||
@ -467,7 +478,6 @@ ActiveRecord::Schema.define(version: 2022_09_09_131300) do
|
|||||||
t.integer "total"
|
t.integer "total"
|
||||||
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.string "payment_state"
|
|
||||||
t.integer "wallet_amount"
|
t.integer "wallet_amount"
|
||||||
t.integer "wallet_transaction_id"
|
t.integer "wallet_transaction_id"
|
||||||
t.string "payment_method"
|
t.string "payment_method"
|
||||||
@ -1171,6 +1181,8 @@ ActiveRecord::Schema.define(version: 2022_09_09_131300) do
|
|||||||
add_foreign_key "invoices", "statistic_profiles"
|
add_foreign_key "invoices", "statistic_profiles"
|
||||||
add_foreign_key "invoices", "wallet_transactions"
|
add_foreign_key "invoices", "wallet_transactions"
|
||||||
add_foreign_key "invoicing_profiles", "users"
|
add_foreign_key "invoicing_profiles", "users"
|
||||||
|
add_foreign_key "order_activities", "invoicing_profiles", column: "operator_profile_id"
|
||||||
|
add_foreign_key "order_activities", "orders"
|
||||||
add_foreign_key "order_items", "orders"
|
add_foreign_key "order_items", "orders"
|
||||||
add_foreign_key "orders", "coupons"
|
add_foreign_key "orders", "coupons"
|
||||||
add_foreign_key "orders", "invoices"
|
add_foreign_key "orders", "invoices"
|
||||||
|
18
yarn.lock
18
yarn.lock
@ -3299,20 +3299,10 @@ caniuse-api@^3.0.0:
|
|||||||
lodash.memoize "^4.1.2"
|
lodash.memoize "^4.1.2"
|
||||||
lodash.uniq "^4.5.0"
|
lodash.uniq "^4.5.0"
|
||||||
|
|
||||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001313:
|
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001313, caniuse-lite@^1.0.30001332:
|
||||||
version "1.0.30001314"
|
version "1.0.30001397"
|
||||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001314.tgz#65c7f9fb7e4594fca0a333bec1d8939662377596"
|
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001397.tgz"
|
||||||
integrity sha512-0zaSO+TnCHtHJIbpLroX7nsD+vYuOVjl3uzFbJO1wMVbuveJA0RK2WcQA9ZUIOiO0/ArMiMgHJLxfEZhQiC0kw==
|
integrity sha512-SW9N2TbCdLf0eiNDRrrQXx2sOkaakNZbCjgNpPyMJJbiOrU5QzMIrXOVMRM1myBXTD5iTkdrtU/EguCrBocHlA==
|
||||||
|
|
||||||
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==
|
|
||||||
|
|
||||||
chalk@^2.0.0, chalk@^2.4.2:
|
chalk@^2.0.0, chalk@^2.4.2:
|
||||||
version "2.4.2"
|
version "2.4.2"
|
||||||
|
Loading…
Reference in New Issue
Block a user