1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-19 13:54:25 +01:00

(merge) store manage the orders by admin

This commit is contained in:
Du Peng 2022-09-16 11:45:58 +02:00
commit 010718d53e
56 changed files with 982 additions and 333 deletions

View 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

View File

@ -0,0 +1,3 @@
# Raised when update order state error
class UpdateOrderStateError < StandardError
end

View 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;
}
}

View File

@ -14,9 +14,9 @@ import { Order } from '../../models/order';
import { MemberSelect } from '../user/member-select';
import { CouponInput } from '../coupon/coupon-input';
import { Coupon } from '../../models/coupon';
import { computePriceWithCoupon } from '../../lib/coupon';
import noImage from '../../../../images/no_image.png';
import Switch from 'react-switch';
import OrderLib from '../../lib/order';
declare const Application: IApplication;
@ -82,7 +82,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
* Handle payment
*/
const handlePaymentSuccess = (data: Order): void => {
if (data.payment_state === 'paid') {
if (data.state === 'paid') {
setPaymentModal(false);
window.location.href = '/#!/store';
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
*/
const handleChangeMember = (userId: number): void => {
setCart({ ...cart, user: { id: userId, role: 'member' } });
const handleChangeMember = (user: User): void => {
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 (
<div className='store-cart'>
<div className="store-cart-list">
@ -185,10 +139,10 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
{cart && cart.order_items_attributes.map(item => (
<article key={item.id} className='store-cart-list-item'>
<div className='picture'>
<img alt=''src={noImage} />
<img alt=''src={item.orderable_main_image_url || noImage} />
</div>
<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>
</div>
<div className="actions">
@ -197,13 +151,13 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
<span>/ {t('app.public.store_cart.unit')}</span>
</div>
<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>
))}
</select>
<div className='total'>
<span>{t('app.public.store_cart.total')}</span>
<p>{FormatLib.price(itemAmount(item))}</p>
<p>{FormatLib.price(OrderLib.itemAmount(item))}</p>
</div>
<FabButton className="main-action-btn" onClick={removeProductFromCart(item)}>
<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>
<span>{t('app.public.store_cart.checkout_products_COUNT', { COUNT: cart?.order_items_attributes.length })}</span>
<div className="list">
<p>{t('app.public.store_cart.checkout_products_total')} <span>{FormatLib.price(totalBeforeOfferedAmount())}</span></p>
{hasOfferedItem() &&
<p className='gift'>{t('app.public.store_cart.checkout_gift_total')} <span>-{FormatLib.price(offeredAmount())}</span></p>
<p>{t('app.public.store_cart.checkout_products_total')} <span>{FormatLib.price(OrderLib.totalBeforeOfferedAmount(cart))}</span></p>
{OrderLib.hasOfferedItem(cart) &&
<p className='gift'>{t('app.public.store_cart.checkout_gift_total')} <span>-{FormatLib.price(OrderLib.offeredAmount(cart))}</span></p>
}
{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>
<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>
<FabButton className='checkout-btn' onClick={checkout} disabled={!cart.user}>
{t('app.public.store_cart.checkout')}

View 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>
</>
);
};

View File

@ -5,6 +5,7 @@ import FormatLib from '../../lib/format';
import { FabButton } from '../base/fab-button';
import { User } from '../../models/user';
import { FabStateLabel } from '../base/fab-state-label';
import OrderLib from '../../lib/order';
interface OrderItemProps {
order?: Order,
@ -19,10 +20,10 @@ export const OrderItem: React.FC<OrderItemProps> = ({ order, currentUser }) => {
/**
* Go to order page
*/
const showOrder = (ref: string) => {
const showOrder = (order: Order) => {
isPrivileged()
? window.location.href = `/#!/admin/store/o/${ref}`
: window.location.href = `/#!/store/o/${ref}`;
? window.location.href = `/#!/admin/store/orders/${order.id}`
: 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');
};
/**
* 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 (
<div className='order-item'>
<p className="ref">order.ref</p>
<p className="ref">{order.reference}</p>
<div>
<FabStateLabel status={statusColor('pending')} background>
order.state
<FabStateLabel status={OrderLib.statusColor(order)} background>
{t(`app.shared.store.order_item.state.${OrderLib.statusText(order)}`)}
</FabStateLabel>
</div>
{isPrivileged() &&
<div className='client'>
<span>{t('app.shared.store.order_item.client')}</span>
<p>order.user.name</p>
<p>{order?.user?.name || ''}</p>
</div>
}
<p className="date">order.created_at</p>
<p className="date">{FormatLib.date(order.created_at)}</p>
<div className='price'>
<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>
<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>
);
};

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
@ -6,10 +6,14 @@ import { IApplication } from '../../models/application';
import { StoreListHeader } from './store-list-header';
import { OrderItem } from './order-item';
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;
interface OrdersDashboardProps {
currentUser: User,
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
*/
// TODO: delete next eslint disable
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const OrdersDashboard: React.FC<OrdersDashboardProps> = ({ onError }) => {
export const OrdersDashboard: React.FC<OrdersDashboardProps> = ({ currentUser, onError }) => {
const { t } = useTranslation('public');
// TODO: delete next eslint disable
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [orders, setOrders] = useState<Array<Order>>([]);
const [pageCount, setPageCount] = useState<number>(0);
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
@ -44,7 +54,26 @@ export const OrdersDashboard: React.FC<OrdersDashboardProps> = ({ onError }) =>
* Display option: sorting
*/
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 (
@ -55,15 +84,17 @@ export const OrdersDashboard: React.FC<OrdersDashboardProps> = ({ onError }) =>
<div className="store-list">
<StoreListHeader
productsCount={0}
productsCount={totalCount}
selectOptions={buildOptions()}
onSelectOptionsChange={handleSorting}
/>
<div className="orders-list">
<OrderItem />
{orders.map(order => (
<OrderItem key={order.id} order={order} currentUser={currentUser} />
))}
</div>
{pageCount > 1 &&
<FabPagination pageCount={pageCount} currentPage={currentPage} selectPage={setCurrentPage} />
<FabPagination pageCount={pageCount} currentPage={currentPage} selectPage={handlePagination} />
}
</div>
</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']));

View File

@ -12,13 +12,14 @@ import { OrderItem } from './order-item';
import { MemberSelect } from '../user/member-select';
import { User } from '../../models/user';
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;
interface OrdersProps {
currentUser?: User,
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
@ -30,26 +31,44 @@ type selectOption = { value: number, label: string };
/**
* 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
*/
// TODO: delete next eslint disable
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
const Orders: React.FC<OrdersProps> = ({ currentUser, onError }) => {
const { t } = useTranslation('admin');
const { register, getValues } = useForm();
const { register, setValue } = useForm();
const [filters, setFilters] = useImmer<Filters>(initFilters);
const [clearFilters, setClearFilters] = useState<boolean>(false);
const [orders, setOrders] = useState<Array<Order>>([]);
const [filters, setFilters] = useImmer<OrderIndexFilter>(window[FablabOrdersFilters] || initFilters);
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(() => {
applyFilters();
setClearFilters(false);
}, [clearFilters]);
window[FablabOrdersFilters] = filters;
OrderAPI.index(filters).then(res => {
setPageCount(res.total_pages);
setTotalCount(res.total_count);
setOrders(res.data);
}).catch(onError);
}, [filters]);
/**
* Create a new order
@ -59,21 +78,78 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
};
const statusOptions: checklistOption[] = [
{ value: 0, label: t('app.admin.store.orders.status.error') },
{ value: 1, label: t('app.admin.store.orders.status.canceled') },
{ value: 2, label: t('app.admin.store.orders.status.pending') },
{ value: 3, label: t('app.admin.store.orders.status.under_preparation') },
{ value: 4, label: t('app.admin.store.orders.status.paid') },
{ value: 5, label: t('app.admin.store.orders.status.ready') },
{ value: 6, label: t('app.admin.store.orders.status.collected') },
{ value: 7, label: t('app.admin.store.orders.status.refunded') }
{ value: 'cart', label: t('app.admin.store.orders.state.cart') },
{ value: 'paid', label: t('app.admin.store.orders.state.paid') },
{ value: 'payment_failed', label: t('app.admin.store.orders.state.payment_failed') },
{ value: 'in_progress', label: t('app.admin.store.orders.state.in_progress') },
{ value: 'ready', label: t('app.admin.store.orders.state.ready') },
{ value: 'canceled', label: t('app.admin.store.orders.state.canceled') }
];
/**
* Apply filters
*/
const applyFilters = () => {
console.log('Apply filters:', filters);
const applyFilters = (filterType: string) => {
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 = () => {
setFilters(initFilters);
setClearFilters(true);
console.log('Clear all filters');
setReference('');
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') }
];
};
/**
* Display option: sorting
*/
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
*/
const handleSelectStatus = (s: checklistOption, checked) => {
const list = [...filters.status];
const handleSelectStatus = (s: checklistOption, checked: boolean) => {
const list = [...states];
checked
? list.push(s)
: list.splice(list.indexOf(s), 1);
setFilters(draft => {
return { ...draft, status: list };
});
? list.push(s.value)
: list.splice(list.indexOf(s.value), 1);
setStates(list);
};
/**
* Filter: by member
*/
const handleSelectMember = (userId: number) => {
setFilters(draft => {
return { ...draft, memberId: userId };
});
const handleSelectMember = (user: User) => {
setUser(user);
};
/**
* Filter: by period
*/
const handlePeriod = () => {
console.log(getValues(['period_from', 'period_to']));
const handlePeriodChanged = (period: string) => {
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 });
};
/**
* Handle orders pagination
*/
const handlePagination = (page: number) => {
setFilters(draft => {
draft.page = page;
});
};
return (
<div className='orders'>
<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>
</div>
</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">
<AccordionItem id={0}
isOpen={accordion[0]}
@ -163,8 +273,8 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
>
<div className='content'>
<div className="group">
<input type="text" />
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
<input type="text" value={reference} onChange={(event) => handleReferenceChanged(event.target.value)}/>
<FabButton onClick={applyFilters('reference')} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
</div>
</div>
</AccordionItem>
@ -177,12 +287,12 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
<div className="group u-scrollbar">
{statusOptions.map(s => (
<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>
</label>
))}
</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>
</AccordionItem>
<AccordionItem id={2}
@ -192,8 +302,8 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
>
<div className='content'>
<div className="group">
<MemberSelect noHeader onSelected={handleSelectMember} />
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
<MemberSelect noHeader value={user as User} onSelected={handleSelectMember} />
<FabButton onClick={applyFilters('user')} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
</div>
</div>
</AccordionItem>
@ -208,13 +318,17 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
from
<FormInput id="period_from"
register={register}
onChange={handlePeriodChanged('period_from')}
defaultValue={periodFrom}
type="date" />
to
<FormInput id="period_to"
register={register}
onChange={handlePeriodChanged('period_to')}
defaultValue={periodTo}
type="date" />
</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>
</AccordionItem>
@ -223,13 +337,19 @@ const Orders: React.FC<OrdersProps> = ({ currentUser, onSuccess, onError }) => {
<div className="store-list">
<StoreListHeader
productsCount={0}
productsCount={totalCount}
selectOptions={buildOptions()}
selectValue={filters.sort === 'ASC' ? 1 : 0}
onSelectOptionsChange={handleSorting}
/>
<div className="orders-list">
<OrderItem currentUser={currentUser} />
{orders.map(order => (
<OrderItem key={order.id} order={order} currentUser={currentUser} />
))}
</div>
{orders.length > 0 &&
<FabPagination pageCount={pageCount} currentPage={filters.page} selectPage={handlePagination} />
}
</div>
</div>
);
@ -243,20 +363,4 @@ const OrdersWrapper: React.FC<OrdersProps> = (props) => {
);
};
Application.Components.component('orders', react2angular(OrdersWrapper, ['currentUser', 'onSuccess', '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
};
Application.Components.component('orders', react2angular(OrdersWrapper, ['currentUser', 'onError']));

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { IApplication } from '../../models/application';
import { User } from '../../models/user';
@ -6,30 +6,35 @@ import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import noImage from '../../../../images/no_image.png';
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;
interface ShowOrderProps {
orderRef: string,
orderId: string,
currentUser?: User,
onSuccess: (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
*/
// TODO: delete next eslint disable
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const ShowOrder: React.FC<ShowOrderProps> = ({ orderRef, currentUser, onError, onSuccess }) => {
export const ShowOrder: React.FC<ShowOrderProps> = ({ orderId, currentUser, onSuccess, onError }) => {
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
*/
@ -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> => {
return [
{ value: 0, label: t('app.shared.store.show_order.status.error') },
{ value: 1, label: t('app.shared.store.show_order.status.canceled') },
{ value: 2, label: t('app.shared.store.show_order.status.pending') },
{ value: 3, label: t('app.shared.store.show_order.status.under_preparation') },
{ value: 4, label: t('app.shared.store.show_order.status.paid') },
{ value: 5, label: t('app.shared.store.show_order.status.ready') },
{ 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';
const paymentInfo = (): string => {
let paymentVerbose = '';
if (order.payment_method === 'card') {
paymentVerbose = t('app.shared.store.show_order.payment.settlement_by_debit_card');
} else if (order.payment_method === 'wallet') {
paymentVerbose = t('app.shared.store.show_order.payment.settlement_by_wallet');
} else {
paymentVerbose = t('app.shared.store.show_order.payment.settlement_done_at_the_reception');
}
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 (
<div className='show-order'>
<header>
<h2>[order.ref]</h2>
<h2>[{order.reference}]</h2>
<div className="grpBtn">
{isPrivileged() &&
<Select
options={buildOptions()}
onChange={option => handleAction(option)}
styles={customStyles}
/>
<OrderActions order={order} onSuccess={handleActionSuccess} onError={onError} />
}
<a href={''}
target='_blank'
className='fab-button is-black'
rel='noreferrer'>
{t('app.shared.store.show_order.see_invoice')}
</a>
{order?.invoice_id && (
<a href={`/api/invoices/${order?.invoice_id}/download`}
target='_blank'
className='fab-button is-black'
rel='noreferrer'>
{t('app.shared.store.show_order.see_invoice')}
</a>
)}
</div>
</header>
<div className="client-info">
<label>{t('app.shared.store.show_order.tracking')}</label>
<div className="content">
{isPrivileged() &&
{isPrivileged() && order.user &&
<div className='group'>
<span>{t('app.shared.store.show_order.client')}</span>
<p>order.user.name</p>
<p>{order.user.name}</p>
</div>
}
<div className='group'>
<span>{t('app.shared.store.show_order.created_at')}</span>
<p>order.created_at</p>
<p>{FormatLib.date(order.created_at)}</p>
</div>
<div className='group'>
<span>{t('app.shared.store.show_order.last_update')}</span>
<p>order.???</p>
<p>{FormatLib.date(order.updated_at)}</p>
</div>
<FabStateLabel status={statusColor('error')} background>
order.state
<FabStateLabel status={OrderLib.statusColor(order)} background>
{t(`app.shared.store.show_order.state.${OrderLib.statusText(order)}`)}
</FabStateLabel>
</div>
</div>
@ -135,43 +129,48 @@ export const ShowOrder: React.FC<ShowOrderProps> = ({ orderRef, currentUser, onE
<div className="cart">
<label>{t('app.shared.store.show_order.cart')}</label>
<div>
{/* loop sur les articles du panier */}
<article className='store-cart-list-item'>
<div className='picture'>
<img alt=''src={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>
{order.order_items_attributes.map(item => (
<article className='store-cart-list-item' key={item.id}>
<div className='picture'>
<img alt=''src={item.orderable_main_image_url || noImage} />
</div>
<span className="count">o.quantity</span>
<div className='total'>
<span>{t('app.shared.store.show_order.item_total')}</span>
<p>o.quantity * o.amount</p>
<div className="ref">
<span>{t('app.shared.store.show_order.reference_short')} {item.orderable_ref || ''}</span>
<p>{item.orderable_name}</p>
</div>
</div>
</article>
<div className="actions">
<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 className="subgrid">
<div className="payment-info">
<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 className="amount">
<label>{t('app.shared.store.show_order.amount')}</label>
<p>{t('app.shared.store.show_order.products_total')}<span>order.amount</span></p>
<p className='gift'>{t('app.shared.store.show_order.gift_total')}<span>-order.amount</span></p>
<p>{t('app.shared.store.show_order.coupon')}<span>order.amount</span></p>
<p className='total'>{t('app.shared.store.show_order.cart_total')} <span>order.total</span></p>
<p>{t('app.shared.store.show_order.products_total')}<span>{FormatLib.price(OrderLib.totalBeforeOfferedAmount(order))}</span></p>
{OrderLib.hasOfferedItem(order) &&
<p className='gift'>{t('app.shared.store.show_order.gift_total')}<span>-{FormatLib.price(OrderLib.offeredAmount(order))}</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>
@ -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']));

View File

@ -7,6 +7,7 @@ interface StoreListHeaderProps {
productsCount: number,
selectOptions: selectOption[],
onSelectOptionsChange: (option: selectOption) => void,
selectValue?: number,
switchLabel?: string,
switchChecked?: boolean,
onSwitch?: (boolean) => void
@ -20,7 +21,7 @@ interface StoreListHeaderProps {
/**
* 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');
// Styles the React-select component
@ -47,6 +48,7 @@ export const StoreListHeader: React.FC<StoreListHeaderProps> = ({ productsCount,
<Select
options={selectOptions}
onChange={evt => onSelectOptionsChange(evt)}
value={selectOptions.find(option => option.value === selectValue)}
styles={customStyles}
/>
</div>

View File

@ -6,7 +6,8 @@ import { User } from '../../models/user';
interface MemberSelectProps {
defaultUser?: User,
onSelected?: (userId: number) => void,
value?: User,
onSelected?: (user: { id: number, name: string }) => void,
noHeader?: boolean
}
@ -19,22 +20,31 @@ type selectOption = { value: number, label: string };
/**
* 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 [value, setValue] = useState<selectOption>();
const [option, setOption] = useState<selectOption>();
useEffect(() => {
if (defaultUser) {
setValue({ value: defaultUser.id, label: defaultUser.name });
setOption({ value: defaultUser.id, label: defaultUser.name });
}
}, []);
useEffect(() => {
if (!defaultUser && value) {
onSelected(value.value);
if (!defaultUser && option) {
onSelected({ id: option.value, name: option.label });
}
}, [defaultUser]);
useEffect(() => {
if (value && value?.id !== option?.value) {
setOption({ value: value.id, label: value.name });
}
if (!value) {
setOption(null);
}
}, [value]);
/**
* search members by name
*/
@ -52,8 +62,8 @@ export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, onSelec
* callback for handle select changed
*/
const onChange = (v: selectOption) => {
setValue(v);
onSelected(v.value);
setOption(v);
onSelected({ id: v.value, name: v.label });
};
return (
@ -68,7 +78,7 @@ export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, onSelec
loadOptions={loadMembers}
defaultOptions
onChange={onChange}
value={value}
value={option}
/>
</div>
);

View File

@ -9,7 +9,7 @@ Application.Controllers.controller('AdminShowOrdersController', ['$rootScope', '
/* PRIVATE SCOPE */
/* PUBLIC SCOPE */
$scope.orderToken = $transition$.params().token;
$scope.orderId = $transition$.params().id;
/**
* Callback triggered in case of error

View File

@ -9,7 +9,7 @@ Application.Controllers.controller('ShowOrdersController', ['$rootScope', '$scop
/* PRIVATE SCOPE */
/* PUBLIC SCOPE */
$scope.orderToken = $transition$.params().token;
$scope.orderId = $transition$.params().id;
/**
* Callback triggered in case of error

View 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;
};
}

View File

@ -16,16 +16,24 @@ export interface Order {
operator_profile_id?: number,
reference?: string,
state?: string,
payment_state?: string,
total?: number,
coupon?: Coupon,
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<{
id: number,
orderable_type: string,
orderable_id: number,
orderable_name: string,
orderable_ref?: string,
orderable_main_image_url?: string,
quantity: number,
quantity_min: number,
amount: number,
is_offered: boolean
}>,
@ -35,3 +43,25 @@ export interface OrderPayment {
order: Order,
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
}

View File

@ -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', {
url: '/wallet',
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
.state('app.public.store_cart', {
url: '/store/cart',
@ -926,7 +924,7 @@ angular.module('application.router', ['ui.router'])
// show order
.state('app.admin.order_show', {
url: '/admin/store/o/:token',
url: '/admin/store/orders/:id',
views: {
'main@': {
templateUrl: '/admin/orders/show.html',

View File

@ -17,6 +17,9 @@
}
.fab-state-label {
--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); }
&.canceled { --status-color: var(--alert-light); }
&.pending { --status-color: var(--information); }
@ -45,4 +48,4 @@
}
p { @include text-base(600); }
}
}
}

View File

@ -21,7 +21,7 @@
}
.show-order {
&-nav {
&-nav {
max-width: 1600px;
margin: 0 auto;
@include grid-col(12);
@ -102,9 +102,12 @@
.fab-state-label {
--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); }
&.canceled { --status-color: var(--alert-light); }
&.pending { --status-color: var(--information); }
&.normal { --status-color: var(--success); }
}
}
}

View File

@ -15,5 +15,5 @@
<span translate>{{ 'app.admin.store.back_to_list' }}</span>
</a>
</div>
<show-order current-user="currentUser" order-ref="orderRef" on-error="onError" on-success="onSuccess" />
</section>
<show-order current-user="currentUser" order-id="orderId" on-error="onError" on-success="onSuccess" />
</section>

View File

@ -5,5 +5,5 @@
</div>
</section>
<orders-dashboard on-error="onError" />
</div>
<orders-dashboard current-user="currentUser" on-error="onError" />
</div>

View File

@ -12,6 +12,6 @@
<span translate>{{ 'app.shared.store.show_order.back_to_list' }}</span>
</a>
</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>
</div>
</div>

View File

@ -69,6 +69,8 @@ class NotificationType
notify_user_is_invalidated
notify_user_proof_of_identity_refusal
notify_admin_user_proof_of_identity_refusal
notify_user_order_is_ready
notify_user_order_is_canceled
]
# deprecated:
# - notify_member_subscribed_plan_is_changed

View File

@ -8,13 +8,11 @@ class Order < PaymentDocument
belongs_to :invoice
has_many :order_items, dependent: :destroy
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
PAYMENT_STATES = %w[paid failed refunded].freeze
enum payment_state: PAYMENT_STATES.zip(PAYMENT_STATES).to_h
validates :token, :state, presence: true
before_create :add_environment

View 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

View File

@ -23,4 +23,8 @@ class Product < ApplicationRecord
validates :amount, numericality: { greater_than: 0, allow_nil: true }
scope :active, -> { where(is_active: true) }
def main_image
product_images.find_by(is_main: true)
end
end

View 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

View File

@ -7,9 +7,11 @@ class Cart::AddItemService
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']
item = order.order_items.find_by(orderable: orderable)
if item.nil?
item = order.order_items.new(quantity: quantity, orderable: orderable, amount: orderable.amount)
else

View File

@ -47,11 +47,11 @@ class Cart::FindOrCreateService
end
@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,
payment_state: 'paid').where('created_at > ?', @order.created_at).last.present?
state: 'paid').where('created_at > ?', @order.created_at).last.present?
@order = nil
end
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
end
end
@ -60,7 +60,7 @@ class Cart::FindOrCreateService
def set_last_cart_if_user_login
if @user&.member?
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.where(statistic_profile_id: @user.statistic_profile.id,
state: 'cart').where('created_at > ?', last_paid_order.created_at).last
@ -70,7 +70,7 @@ class Cart::FindOrCreateService
end
if @user&.privileged?
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.where(operator_profile_id: @user.invoicing_profile.id,
state: 'cart').where('created_at > ?', last_paid_order.created_at).last
@ -85,7 +85,7 @@ class Cart::FindOrCreateService
last_unpaid_order = nil
if @user&.member?
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
Order.where(statistic_profile_id: @user.statistic_profile.id,
state: 'cart').where('created_at > ?', last_paid_order.created_at).last
@ -95,7 +95,7 @@ class Cart::FindOrCreateService
end
if @user&.privileged?
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
Order.where(operator_profile_id: @user.invoicing_profile.id,
state: 'cart').where('created_at > ?', last_paid_order.created_at).last

View File

@ -5,6 +5,8 @@ class Cart::SetQuantityService
def call(order, orderable, quantity = nil)
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']
item = order.order_items.find_by(orderable: orderable)

View 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_by_customer', 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

View 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

View File

@ -2,6 +2,53 @@
# Provides methods for Order
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')
order.order_items.each do |item|
return false if item.orderable.stock[stock_type] < item.quantity

View 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

View File

@ -27,11 +27,11 @@ module Payments::PaymentConcern
else
payment_method
end
order.state = 'in_progress'
order.payment_state = 'paid'
order.state = 'paid'
if payment_id && payment_type
order.payment_gateway_object = PaymentGatewayObject.new(gateway_object_id: payment_id, gateway_object_type: payment_type)
end
order.order_activities.create(activity_type: 'paid')
order.order_items.each do |item|
ProductService.update_stock(item.orderable,
[{ stock_type: 'external', reason: 'sold', quantity: item.quantity, order_item_id: item.id }]).save

View File

@ -31,7 +31,7 @@ class Payments::PayzenService
o = payment_success(order, coupon_code, 'card', payment_id, 'PayZen::Order')
{ order: o }
else
order.update(payment_state: 'failed')
order.update(state: 'payment_failed')
{ order: order, payment: { error: { statusText: payzen_order['answer'] } } }
end
end

View File

@ -39,7 +39,7 @@ class Payments::StripeService
o = payment_success(order, coupon_code, 'card', intent.id, intent.class.name)
{ order: o }
else
order.update(payment_state: 'failed')
order.update(state: 'payment_failed')
{ order: order, payment: { error: { statusText: 'payment failed' } } }
end
end

View File

@ -54,6 +54,7 @@ class ProductService
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

View File

@ -79,13 +79,13 @@ class WalletService
# @param user {User} the customer
# @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
payment.payment_schedule_items.first.amount
else
payment.total
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

View File

@ -0,0 +1,2 @@
json.title notification.notification_type
json.description t('.order_canceled', REFERENCE: notification.attached_object.order.reference)

View File

@ -0,0 +1,2 @@
json.title notification.notification_type
json.description t('.order_ready', REFERENCE: notification.attached_object.order.reference)

View File

@ -1,7 +1,17 @@
# 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.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
json.user do
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_id item.orderable_id
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_min item.orderable.quantity_min
json.amount item.amount / 100.0
json.is_offered item.is_offered
end

View 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

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/orders/order', order: @order

View File

@ -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>

View File

@ -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>

View File

@ -2047,14 +2047,13 @@ en:
filter_status: "By status"
filter_client: "By client"
filter_period: "By period"
status:
error: "Payment error"
canceled: "Canceled"
pending: "Pending payment"
under_preparation: "Under preparation"
state:
cart: 'Cart'
in_progress: 'In progress'
paid: "Paid"
payment_failed: "Payment error"
canceled: "Canceled"
ready: "Ready"
collected: "Collected"
refunded: "Refunded"
sort:
newest: "Newest first"

View File

@ -23,6 +23,7 @@ fr:
my_invoices: "Mes factures"
my_payment_schedules: "Mes échéanciers"
my_wallet: "Mon porte-monnaie"
my_orders: "Mes commandes"
#contextual help
help: "Aide"
#login/logout

View File

@ -562,8 +562,16 @@ en:
main_image: "Main image"
store:
order_item:
total: "Total"
client: "Client"
total: "Total"
client: "Client"
state:
cart: 'Cart'
in_progress: 'In progress'
paid: "Paid"
payment_failed: "Payment error"
canceled: "Canceled"
ready: "Ready"
refunded: "Refunded"
show_order:
back_to_list: "Back to list"
see_invoice: "See invoice"
@ -581,15 +589,40 @@ en:
gift_total: "Discount total"
coupon: "Coupon"
cart_total: "Cart total"
status:
error: "Payment error"
canceled: "Canceled"
pending: "Pending payment"
under_preparation: "Under preparation"
state:
cart: 'Cart'
in_progress: 'In progress'
paid: "Paid"
payment_failed: "Payment error"
canceled: "Canceled"
ready: "Ready"
collected: "Collected"
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?"

View File

@ -121,7 +121,7 @@ en:
error_invoice: "Erroneous invoice. The items below ware not booked. Please contact the FabLab for a refund."
prepaid_pack: "Prepaid pack of hours"
pack_item: "Pack of %{COUNT} hours for the %{ITEM}"
order: "Order of products"
order: "Your order of the store"
#PDF payment schedule generation
payment_schedules:
schedule_reference: "Payment schedule reference: %{REF}"
@ -407,6 +407,10 @@ en:
refusal: "Your proof of identity are not accepted"
notify_admin_user_proof_of_identity_refusal:
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:
subscriptions: "Subscriptions"

View File

@ -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."
prepaid_pack: "Paquet d'heures prépayé"
pack_item: "Pack de %{COUNT} heures pour la %{ITEM}"
order: "La commande des produits"
order: "Votre commande de la boutique"
#PDF payment schedule generation
payment_schedules:
schedule_reference: "Référence de l'échéancier : %{REF}"

View File

@ -374,3 +374,11 @@ en:
user_proof_of_identity_files_refusal: "Member %{NAME}'s supporting documents were rejected by %{OPERATOR}:"
shared:
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:"

View File

@ -167,6 +167,7 @@ Rails.application.routes.draw do
post 'payment', on: :collection
post 'confirm_payment', on: :collection
end
resources :orders, except: %i[create]
# for admin
resources :trainings do

View File

@ -0,0 +1,5 @@
class RemovePaymentStateFromOrders < ActiveRecord::Migration[5.2]
def change
remove_column :orders, :payment_state
end
end

View 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

View File

@ -10,7 +10,7 @@
#
# 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
enable_extension "fuzzystrmatch"
@ -445,6 +445,17 @@ ActiveRecord::Schema.define(version: 2022_09_09_131300) do
t.datetime "updated_at", null: false
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|
t.bigint "order_id"
t.string "orderable_type"
@ -467,7 +478,6 @@ ActiveRecord::Schema.define(version: 2022_09_09_131300) do
t.integer "total"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "payment_state"
t.integer "wallet_amount"
t.integer "wallet_transaction_id"
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", "wallet_transactions"
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 "orders", "coupons"
add_foreign_key "orders", "invoices"