diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..5875dc5 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "baseUrl": "src" + }, + "include": ["src"] +} diff --git a/src/App.jsx b/src/App.jsx index e280a0e..5b1c742 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,10 +2,10 @@ import React from 'react'; import { BrowserRouter, Route, Switch } from 'react-router-dom'; import { Provider } from 'react-redux'; import store from './redux/store'; -import actions from './redux/actions'; -import { Box } from '@material-ui/core'; +import apiActions from './redux/actions/api'; // Style +import { Box } from '@material-ui/core'; import CssBaseline from '@material-ui/core/CssBaseline'; import { ThemeProvider } from '@material-ui/core/styles'; import { MuiPickersUtilsProvider } from '@material-ui/pickers'; @@ -29,7 +29,7 @@ import Error404 from './pages/Error404'; // Public pages import Sales from './pages/public/Sales'; import SaleDetail from './pages/public/SaleDetail'; -import Orders from './pages/public/Orders'; +import UserOrders from './pages/public/UserOrders'; import OrderDetail from './pages/public/OrderDetail'; import ContactForm from "./pages/public/ContactForm"; @@ -58,7 +58,7 @@ class App extends React.Component { componentDidMount() { // Get connected user - store.dispatch(actions.auth().all()); + store.dispatch(apiActions.authUser.get()); } render() { @@ -81,7 +81,7 @@ class App extends React.Component { - + } /> } /> diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 01535f1..6de6fee 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -1,13 +1,14 @@ import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import actions from '../redux/actions'; +import apiActions from 'redux/actions/api'; import { makeStyles } from '@material-ui/core/styles'; import { AppBar, Toolbar, Container, Button, Menu, Divider, useMediaQuery } from '@material-ui/core'; import { MoreVert, Home, ShoppingCart, AccountCircle } from '@material-ui/icons'; import { NavLink } from 'react-router-dom'; -import { NavButton, NavMenuItem } from './common/Nav.jsx'; -import { hasManagerRights, textOrIcon } from '../utils'; +import { NavButton, NavMenuItem } from 'components/common/Nav.jsx'; +import { textOrIcon } from 'utils/format'; +import { hasManagerRights } from 'utils/api'; const useStyles = makeStyles(theme => ({ @@ -30,12 +31,12 @@ export default function Header(props) { const [menuTarget, setMenuTarget] = React.useState(null); const dispatch = useDispatch(); - const auth = useSelector(store => store.getData('auth', {})); - const userAssos = useSelector(store => store.getAuthRelatedData('associations', null)); + const auth = useSelector(store => store.api.getData('auth', {})); + const userAssos = useSelector(store => store.api.getAuthRelatedData('associations', null)); React.useEffect(() => { if (auth.user && !userAssos) - dispatch(actions.auth(auth.user.id).associations.all({ page_size: 'max' })); + dispatch(apiActions.authUser(auth.user.id).associations.all({ page_size: 'max' })); }); return ( diff --git a/src/components/MainLoader.jsx b/src/components/MainLoader.jsx index 57e22dd..ef932eb 100644 --- a/src/components/MainLoader.jsx +++ b/src/components/MainLoader.jsx @@ -18,11 +18,12 @@ const useStyles = makeStyles({ export default function MainLoader(props) { const classes = useStyles(); - const auth = useSelector(store => store.get('auth')); + const auth = useSelector(store => store.api.get('auth')); // Fail if cannot get auth if (auth.error) { console.warn(auth.error) + // TODO Deal with errors throw new Error("Impossible de contacter le serveur") } diff --git a/src/components/MessageSystem.jsx b/src/components/MessageSystem.jsx index 9737683..3767b11 100644 --- a/src/components/MessageSystem.jsx +++ b/src/components/MessageSystem.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { Snackbar } from '@material-ui/core'; import { Alert, AlertTitle } from '@material-ui/lab'; -import { messagesActions } from '../redux/actions'; +import messagesActions from '../redux/actions/messages'; export function Message({ title, details=null, severity="info", onClose, ...props }) { diff --git a/src/components/common/APIDataTable.jsx b/src/components/common/APIDataTable.jsx new file mode 100644 index 0000000..9e893ed --- /dev/null +++ b/src/components/common/APIDataTable.jsx @@ -0,0 +1,110 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { useStoreAPIData } from "redux/hooks"; +import { processPagination, pathToArray } from "redux/reducers/api"; +import { DEFAULT_PAGE_SIZE, PAGE_SIZES } from "utils/constants"; + +import MaterialTable from "material-table"; +import { + Add, Check, Clear, Delete, Edit, GetApp, FilterList, ArrowUpward, + FirstPage, LastPage, NavigateNext, NavigateBefore, Search, ViewColumn +} from '@material-ui/icons'; + + +export const MaterialTableIcons = { + Add: Add, + Check: Check, + Clear: Clear, + Delete: Delete, + // DetailPanel: DetailPanel, + Edit: Edit, + Export: GetApp, + Filter: FilterList, + FirstPage: FirstPage, + LastPage: LastPage, + NextPage: NavigateNext, + PreviousPage: NavigateBefore, + ResetSearch: Clear, + Search: Search, + SortArrow: ArrowUpward, + // ThirdStateCheck: ThirdStateCheck, + ViewColumn: ViewColumn, +}; + +/* +query = { + error: undefined, + filters: [], + orderBy: undefined, + orderDirection: "", + page: 0, + pageSize: 5, + search: "", + totalCount: 12, +} +*/ +// TODO Add sort, search, filtering functionalities +export function paginateResource(resource, transformData = null) { + async function paginateData(query) { + const page = query.page + 1; + const shouldFetch = ( + !resource.fetched + || !resource.pagination + || query.pageSize !== resource.pagination.pageSize + || (resource.pagination.fetchedPages || {})[page] == null + ); + + if (shouldFetch) { + const queryParams = { page, page_size: query.pageSize }; + const resp = await resource.fetchData(queryParams, true).payload; + const { data, pagination } = processPagination(resp.data); + return { + data: transformData ? transformData(data) : data, + page: query.page, + totalCount: pagination ? pagination.count : data.length, + } + } else { + const pagination = resource.pagination; + const data = pagination + ? pagination.fetchedPages[page].map(id => resource.data[id]) + : resource.data; + return { + data: transformData ? transformData(data) : data, + page: query.page, + totalCount: pagination ? pagination.count : data.length, + }; + } + } + return paginateData; +} + +export default function APIDataTable({ path, queryParams = {}, apiOptions = {}, transformData = null, options = {}, ...props }) { + const [pageSize, setPageSize] = React.useState(options.pageSize || DEFAULT_PAGE_SIZE); + const _queryParams = { ...queryParams, page_size: pageSize }; + const resource = useStoreAPIData(pathToArray(path), _queryParams, apiOptions); + + return ( + + ); +} + +APIDataTable.propTypes = { + path: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.array, + ]).isRequired, + queryParams: PropTypes.object, + apiOptions: PropTypes.object, + transformData: PropTypes.func, + options: PropTypes.object, +}; diff --git a/src/components/common/DetailsTable.jsx b/src/components/common/DetailsTable.jsx index 779c329..ff5faea 100644 --- a/src/components/common/DetailsTable.jsx +++ b/src/components/common/DetailsTable.jsx @@ -1,63 +1,66 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import { TableContainer, Table, TableBody, TableCell, TableRow } from '@material-ui/core'; -import { SkeletonTable } from './Skeletons'; - +import React from "react"; +import PropTypes from "prop-types"; +import { makeStyles } from "@material-ui/core/styles"; +import { TableContainer, Table, TableBody, TableCell, TableRow } from "@material-ui/core"; +import { SkeletonTable } from "./Skeletons"; const useStyles = makeStyles({ - topborder: { - borderTop: '1px solid rgba(224, 224, 224, 1)', - }, - label: { - fontWeight: 500, - fontSize: '1em', - paddingRight: '1em', - }, - value: { - fontWeight: 200, - }, + topborder: { + borderTop: "1px solid rgba(224, 224, 224, 1)", + }, + label: { + fontWeight: 500, + fontSize: "1em", + paddingRight: "1em", + }, + value: { + fontWeight: 200, + }, }); function renderDetailValue(value) { - if (typeof value === "object") - return JSON.stringify(value); + if (typeof value === "object") { + return JSON.stringify(value); + } - return value; + return value; } -export default function DetailsTable({ data, labels, renderValue }) { - const classes = useStyles(); - if (!data) - return +export default function DetailsTable({ data, fetched, labels, renderValue }) { + const classes = useStyles(); + if (!fetched) { + return ; + } - const keys = Object.keys(labels || data); - return ( - - - - {keys.map((key, index) => ( - - - {labels ? labels[key] : key} - - - {renderValue(data[key])} - - - ))} - - - - ); + const keys = Object.keys(labels || data); + return ( + + + + {keys.map((key, index) => ( + + + {labels ? labels[key] : key} + + {renderValue(data[key])} + + ))} + + + + ); } DetailsTable.propTypes = { - data: PropTypes.object, - labels: PropTypes.object, - renderValue: PropTypes.func, + data: PropTypes.object, + fetched: PropTypes.bool, + labels: PropTypes.object, + renderValue: PropTypes.func, }; DetailsTable.defaultProps = { - renderValue: renderDetailValue, + data: undefined, + fetched: undefined, + labels: undefined, + renderValue: renderDetailValue, }; diff --git a/src/components/common/Loader.jsx b/src/components/common/Loader.jsx index 4454635..46e353c 100644 --- a/src/components/common/Loader.jsx +++ b/src/components/common/Loader.jsx @@ -1,8 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { withStyles } from '@material-ui/core/styles'; -import { CircularProgress } from '@material-ui/core'; -import { capitalFirst } from '../../utils'; +import { CircularProgress } from '@material-ui/core'; +import { capitalFirst } from 'utils/format'; const FLEX_DIRECTIONS = { right: 'row', diff --git a/src/components/common/ProtectedRoute.jsx b/src/components/common/ProtectedRoute.jsx index a9cbd54..18e9ad2 100644 --- a/src/components/common/ProtectedRoute.jsx +++ b/src/components/common/ProtectedRoute.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { Route, Redirect } from 'react-router-dom'; -import { hasManagerRights } from '../../utils'; +import { hasManagerRights } from 'utils/api'; const authFunctions = { @@ -10,20 +10,20 @@ const authFunctions = { return auth.authenticated; }, admin(auth, userAssos, props) { - return auth.authenticated && auth.is_admin; + return auth.authenticated && auth.user?.is_admin; }, manager(auth, userAssos, props) { return hasManagerRights(auth, userAssos); }, asso_manager(auth, userAssos, props) { const asso_id = this.props.computedMatch.params.asso_id; - return hasManagerRights(auth, userAssos, props) && (asso_id in userAssos || auth.is_admin); + return hasManagerRights(auth, userAssos, props) && (asso_id in userAssos || auth.user?.is_admin); }, }; export default function ProtectedRoute({ only, authOptions, redirection, component: Component, ...routeProps }) { - const auth = useSelector(store => store.getData('auth')); - const userAssos = useSelector(store => store.getAuthRelatedData('associations')); + const auth = useSelector(store => store.api.getData('auth')); + const userAssos = useSelector(store => store.api.getAuthRelatedData('associations')); const isAuthorized = ( typeof only == 'function' @@ -32,7 +32,7 @@ export default function ProtectedRoute({ only, authOptions, redirection, compone ); return ( ( isAuthorized ? : diff --git a/src/components/orders/OrderButtons.jsx b/src/components/orders/OrderButtons.jsx new file mode 100644 index 0000000..0c95f51 --- /dev/null +++ b/src/components/orders/OrderButtons.jsx @@ -0,0 +1,75 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Chip, Button, IconButton, CircularProgress } from "@material-ui/core"; + +import { ORDER_STATUS } from "utils/constants"; + + +export function OrderStatusButton({ status, updateStatus, variant, updating, ...props }) { + if (typeof status === "number") + status = ORDER_STATUS[status] || {}; + + const Component = { + chip: Chip, + button: Button, + text: "span", + }[variant]; + + let _props = { + onClick: updateStatus, + ...props, + style: { + backgroundColor: variant === "chip" ? status?.color : undefined, + color: variant === "chip" ? "#fff" : status?.color, + cursor: updateStatus ? "pointer" : null, + ...props.style, + }, + [variant === "chip" ? "label" : "children"]: status?.label || "Inconnu", + }; + + const icon = updating ? : null; + if (variant === "chip") { + _props.icon = icon; + _props.clickable = Boolean(updateStatus); + } else if (variant === "button") { + _props.startIcon = icon; + } + + return ; +} + +OrderStatusButton.propTypes = { + status: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.number, + ]).isRequired, + variant: PropTypes.oneOf(["button", "chip", "text"]), + updateStatus: PropTypes.func, + updating: PropTypes.bool, +}; + +OrderStatusButton.defaultProps = { + variant: "text", +}; + + +export function OrderActionButton({ order, text, Icon, onClick, ...props }) { + return ( + + + + ); +} + +OrderActionButton.propTypes = { + order: PropTypes.object.isRequired, + text: PropTypes.string.isRequired, + Icon: PropTypes.object.isRequired, + onClick: PropTypes.func.isRequired, +}; diff --git a/src/components/orders/OrderLineItemTicket.jsx b/src/components/orders/OrderLineItemTicket.jsx index 93b1ab1..52f65a4 100644 --- a/src/components/orders/OrderLineItemTicket.jsx +++ b/src/components/orders/OrderLineItemTicket.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { Box, Paper, TextField } from '@material-ui/core'; -import { isEmpty } from '../../utils'; +import { isEmpty } from 'utils/helpers'; // TODO Different Input types diff --git a/src/components/orders/OrderLinesList.jsx b/src/components/orders/OrderLinesList.jsx index 7039545..a52ca49 100644 --- a/src/components/orders/OrderLinesList.jsx +++ b/src/components/orders/OrderLinesList.jsx @@ -1,8 +1,8 @@ -import React from 'react' -import PropTypes from 'prop-types'; -import { List, ListItem, ListItemText } from '@material-ui/core'; -import { SkeletonList } from '../common/Skeletons'; -import { isEmpty } from '../../utils'; +import React from "react" +import PropTypes from "prop-types"; +import { List, ListItem, ListItemText } from "@material-ui/core"; +import { SkeletonList } from "components/common/Skeletons"; +import { isEmpty } from "utils/helpers"; export default function OrderLinesList({ orderlines, items, prefix, empty, ...props }) { @@ -12,20 +12,12 @@ export default function OrderLinesList({ orderlines, items, prefix, empty, ...pr if (isEmpty(orderlines)) return empty; - function getItem(item) { - if (item.name) - return item.name; - else if (items && items.hasOwnProperty(item)) - return items[item].name; - return '...'; - } - return ( - {Object.values(orderlines).map(orderline => ( - + {Object.values(orderlines).map(({ id, quantity, item }, index) => ( + - {prefix}{orderline.quantity} × {getItem(orderline.item)} + {prefix}{quantity} × {item?.name || item?.[item]?.name || "..."} ))} @@ -41,6 +33,6 @@ OrderLinesList.propTypes = { }; OrderLinesList.defaultProps = { - empty: 'Aucun article', - prefix: '', + empty: "Aucun article", + prefix: "", }; diff --git a/src/components/orders/OrdersTable.jsx b/src/components/orders/OrdersTable.jsx new file mode 100644 index 0000000..4c31625 --- /dev/null +++ b/src/components/orders/OrdersTable.jsx @@ -0,0 +1,137 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { useHistory } from "react-router"; +import { useDispatch } from "react-redux"; + +import { formatDate } from "utils/format"; +import { updateOrderStatus, getStatusActions } from "utils/api"; +import { ORDER_STATUS, ORDER_ACTIONS } from "utils/constants"; + +import APIDataTable from "components/common/APIDataTable"; +import { Link } from "components/common/Nav"; +import { OrderStatusButton, OrderActionButton } from "./OrderButtons"; +import OrderLinesList from "./OrderLinesList"; + + +export default function OrdersTable({ items, show, queryParams = {}, ...props }) { + const dispatch = useDispatch(); + const history = useHistory(); + const statusActions = getStatusActions(dispatch, history); + + // Specify columns + const showMap = new Set(show); + const columns = [ + { + title: "N°", + field: "id", + render: (order) => ( + {order.id} + ), + }, + { + title: "Acheteur", + field: "owner", + hidden: !showMap.has("owner"), + }, + { + title: "Vente", + field: "sale", + hidden: !showMap.has("sale"), + render: (order) => ( + {order.sale?.name} + ), + }, + { + title: "Status", + field: "status", + render: (order) => ( + updateOrderStatus(dispatch, order.id, { fetch: true })} + variant="button" + /> + ), + }, + { + title: "Action", + field: "status", + searchable: false, + hidden: !showMap.has("actions"), + render: (order) => ( + + {order.status?.actions?.map(key => ( + + ))} + + ), + }, + { + title: "Mise à jour", + field: "updated_at", + searchable: false, + render: (order) => {formatDate(order.updated_at, "datetime")}, + }, + { + title: "Articles", + field: "orderlines", + searchable: false, + render: (order) => ( + + ), + }, + ]; + + // Configure include query params + let include = new Set((queryParams.include || '').split('')) + if (showMap.has("items") && items === undefined) + include.add("orderlines").add("orderlines__item") + if (showMap.has("sale")) + include.add("sale") + if (showMap.has("owner")) + include.add("owner") + const _queryParams = { ...queryParams, include: Array.from(include).join(',') }; + + return ( + orders.map((order) => ({ + id: order.id, + sale: order.sale, + owner: order.owner ? `${order.owner.first_name} ${order.owner.last_name}` : null, + updated_at: order.updated_at, + status: ORDER_STATUS[order.status] || {}, + orderlines: order.orderlines, + }))} + {...props} + /> + ); +} + +OrdersTable.propTypes = { + items: PropTypes.object, + show: PropTypes.arrayOf( + PropTypes.oneOf([ + "sale", + "owner", + "items", + "actions", + ]) + ), +}; + +OrdersTable.defaultProps = { + items: undefined, + show: [], +}; diff --git a/src/components/orders/UserOrdersList.jsx b/src/components/orders/UserOrdersList.jsx index 69927fd..1c963c6 100644 --- a/src/components/orders/UserOrdersList.jsx +++ b/src/components/orders/UserOrdersList.jsx @@ -1,78 +1,20 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { useHistory } from 'react-router'; -import { useDispatch } from 'react-redux' -import actions, { apiAxios } from '../../redux/actions'; +import React from "react"; +import PropTypes from "prop-types"; +import { useHistory } from "react-router"; +import { useDispatch } from "react-redux"; +import { getStatusActions } from "utils/api"; +import { ORDER_STATUS, ORDER_ACTIONS } from "utils/constants"; import { TableContainer, Table, TableHead, TableBody, - TableRow, TableCell, Button, IconButton -} from '@material-ui/core'; -import { Link } from '../common/Nav'; + TableRow, TableCell +} from "@material-ui/core"; +import { Link } from "components/common/Nav"; +import OrderLinesList from "./OrderLinesList"; +import { OrderStatusButton, OrderActionButton } from "./OrderButtons"; -import OrderLinesList from './OrderLinesList'; -import { ORDER_STATUS, ORDER_ACTIONS, API_URL } from '../../constants'; - -function getStatusActions(dispatch, history, fetchOrders) { - // TODO fetch the right order instead of fetching all orders - return { - download(event) { - const orderId = event.currentTarget.getAttribute('data-order-id'); - window.open(`${API_URL}/orders/${orderId}/pdf?download`, '_blank'); - }, - - modify(event) { - const orderId = event.currentTarget.getAttribute('data-order-id'); - history.push(`/orders/${orderId}`); - }, - - pay(event) { - const saleId = event.currentTarget.getAttribute('data-sale-id'); - history.push(`/sales/${saleId}`); - }, - - cancel(event) { - const orderId = event.currentTarget.getAttribute('data-order-id'); - const action = actions.orders(orderId).delete(); - dispatch(action); - action.payload.finally(fetchOrders); - }, - - updateStatus(event) { - const orderId = event.currentTarget.getAttribute('data-order-id'); - apiAxios.get(`/orders/${orderId}/status`).then(resp => { - if (resp.updated) - fetchOrders(); - }); - }, - }; -}; - - -function ActionButton({ order, text, Icon, onClick }) { - return ( - - - - ); -} - -ActionButton.propTypes = { - order: PropTypes.object.isRequired, - text: PropTypes.string.isRequired, - Icon: PropTypes.object.isRequired, - onClick: PropTypes.func.isRequired, -}; - - -function ActionOrderRow({ order, actions }) { +function UserOrderRow({ order, actions }) { const status = ORDER_STATUS[order.status] || {}; return ( @@ -80,20 +22,18 @@ function ActionOrderRow({ order, actions }) { {order.id} - {order.sale && order.sale.name} + {order.sale?.name} - - {status.label} - + /> {status.actions.map(key => ( - @@ -136,7 +76,7 @@ export default function UserOrdersList({ orders, fetchOrders, ...props }) { {Object.values(orders).map(order => ( - ({ +const useStyles = makeStyles((theme) => ({ nested: { paddingLeft: theme.spacing(4), }, })); -export default function AssoSalesList({ assos, sales, ...props}) { - const [isOpen, setOpen] = React.useState({}); +export default function AssoSalesList({ assos, sales, ...props }) { const classes = useStyles(); + const [isOpen, setOpen] = React.useState({}); - const handleOpen = event => { + function handleOpen(event) { event.preventDefault(); - const id = event.currentTarget.getAttribute('value'); - // Ask for sales data if not available - if (!isOpen[id] && !sales[id]) - props.fetchSales(id) + const id = event.currentTarget.getAttribute("value"); + // Toggle collapse setOpen({ ...isOpen, [id]: !isOpen[id] }); + + // Ask for sales data if not available + if (!isOpen[id] && !sales[id]?.fetched) + props.fetchSales(id); + } + + function getFetchMoreHandler(assoId) { + if (!sales[assoId]) + return null; + + const { lastFetched, nbPages } = sales[assoId].pagination; + if (lastFetched < nbPages) + return () => props.fetchSales(assoId, lastFetched + 1); + return null; } return ( @@ -39,7 +50,9 @@ export default function AssoSalesList({ assos, sales, ...props}) { - + - ))} diff --git a/src/components/sales/ItemsTable.jsx b/src/components/sales/ItemsTable.jsx index 694f667..23863bb 100644 --- a/src/components/sales/ItemsTable.jsx +++ b/src/components/sales/ItemsTable.jsx @@ -1,9 +1,9 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React from "react"; +import PropTypes from "prop-types"; -import { Box, TableContainer, Table, TableBody, TableRow, TableCell, TextField } from '@material-ui/core/'; -import { SkeletonTable } from '../common/Skeletons'; -import { formatPrice } from '../../utils'; +import { Box, TableContainer, Table, TableBody, TableRow, TableCell, TextField } from "@material-ui/core"; +import { SkeletonTable } from "components/common/Skeletons"; +import { formatPrice } from "utils/format"; export default function ItemsTable({ items, disabled, quantities, onQuantityChange, ...props }) { diff --git a/src/components/sales/SaleCard.jsx b/src/components/sales/SaleCard.jsx index fcdd64b..15c5bcc 100644 --- a/src/components/sales/SaleCard.jsx +++ b/src/components/sales/SaleCard.jsx @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +<<<<<<< HEAD import {shorten } from '../../utils'; import {makeStyles} from '@material-ui/core/styles'; import {Grid, Card, CardContent, CardActions} from '@material-ui/core'; @@ -7,6 +8,15 @@ import {Skeleton} from '@material-ui/lab'; import {NavButton} from '../common/Nav'; import {isPast} from "date-fns"; import Chip from '@material-ui/core/Chip'; +======= +import { shorten } from 'utils/format'; +import { getSaleState } from "utils/api"; + +import { makeStyles } from '@material-ui/core/styles'; +import { Grid, Card, CardContent, CardActions, Chip } from '@material-ui/core'; +import { Skeleton } from '@material-ui/lab'; +import { NavButton } from 'components/common/Nav'; +>>>>>>> ce994cff935be9d282d472d27815a06f771b0c9e const useStyles = makeStyles(theme => ({ card: { @@ -56,6 +66,7 @@ export function SaleCardSkeleton() { ); } +<<<<<<< HEAD export default function SaleCard({sale, ...props}) { const classes = useStyles(); @@ -100,6 +111,36 @@ export default function SaleCard({sale, ...props}) { +======= + +export default function SaleCard({sale, ...props}) { + const classes = useStyles(); + const currentSaleState = getSaleState(sale); + return ( + + + + + {sale.name} + + + Par {sale.association?.shortname || "..."} + + + {shorten(sale.description, 150)} + + + + + + Accéder à la vente + + +>>>>>>> ce994cff935be9d282d472d27815a06f771b0c9e ); diff --git a/src/components/sales/SalesList.jsx b/src/components/sales/SalesList.jsx index c0388f7..31c538c 100644 --- a/src/components/sales/SalesList.jsx +++ b/src/components/sales/SalesList.jsx @@ -1,15 +1,15 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { List, ListItem, ListItemSecondaryAction, ListItemText } from '@material-ui/core'; +import { List, ListItem, ListItemSecondaryAction, ListItemText, IconButton } from '@material-ui/core'; import { NavListItem, NavIconButton } from '../common/Nav'; -import { Add, Edit } from '@material-ui/icons'; -import { SkeletonList } from '../../components/common/Skeletons'; -import { isEmpty } from '../../utils'; +import { Add, Edit, Search } from '@material-ui/icons'; +import { SkeletonList } from 'components/common/Skeletons'; +import { isEmpty } from 'utils/helpers'; -export default function SalesList({ sales, baseUrl, withEdit, assoId, ...props }) { +export default function SalesList({ sales, fetched, fetchMore, baseUrl, withEdit, assoId, ...props }) { - if (!sales) + if (!fetched) return ; const createSaleLink = { @@ -18,7 +18,7 @@ export default function SalesList({ sales, baseUrl, withEdit, assoId, ...props } }; return ( - {isEmpty(sales) ? ( + {isEmpty(sales) ? ( @@ -40,6 +40,16 @@ export default function SalesList({ sales, baseUrl, withEdit, assoId, ...props } )) )} + {fetchMore && ( + + + + + + + + + )} {withEdit && ( @@ -50,12 +60,14 @@ export default function SalesList({ sales, baseUrl, withEdit, assoId, ...props } )} - + ); } SalesList.propTypes = { sales: PropTypes.object, + fetched: PropTypes.bool, + fetchMore: PropTypes.func, baseUrl: PropTypes.string, withEdit: PropTypes.bool, assoId: PropTypes.string, @@ -63,6 +75,8 @@ SalesList.propTypes = { SalesList.defaultProps = { sales: null, + fetched: false, + fetchMore: undefined, baseUrl: '', withEdit: false, assoId: null, diff --git a/src/components/users/AccountDetails.jsx b/src/components/users/AccountDetails.jsx index 427d3e5..e82340c 100644 --- a/src/components/users/AccountDetails.jsx +++ b/src/components/users/AccountDetails.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { makeStyles } from '@material-ui/core/styles'; import { TableContainer, Table, TableBody, TableCell, TableRow } from '@material-ui/core'; -import { capitalFirst } from '../../utils'; +import { capitalFirst } from 'utils/format'; const useStyles = makeStyles({ diff --git a/src/pages/Account.jsx b/src/pages/Account.jsx index 202dcc0..12a15af 100644 --- a/src/pages/Account.jsx +++ b/src/pages/Account.jsx @@ -1,17 +1,34 @@ -import React from 'react'; -import { useSelector } from 'react-redux'; -import { useUserOrders } from '../redux/hooks'; +import React from "react"; +import { useDispatch, useSelector } from "react-redux"; +import apiActions from "../redux/actions/api"; -import { Container, Grid } from '@material-ui/core'; -import Loader from '../components/common/Loader'; +import { Container, Grid, Box } from "@material-ui/core"; +import { NavButton } from "../components/common/Nav"; +import Loader from "../components/common/Loader"; -import AccountDetails from '../components/users/AccountDetails'; -import UserOrdersList from '../components/orders/UserOrdersList'; +import AccountDetails from "../components/users/AccountDetails"; +import UserOrdersList from "../components/orders/UserOrdersList"; export default function Account(props) { - const user = useSelector(store => store.getAuthUser()); - const { orders, fetchOrders } = useUserOrders(); + const dispatch = useDispatch(); + const user = useSelector(store => store.api.getAuthUser()); + const orders = useSelector(store => store.api.getAuthRelatedData("lastOrders", undefined)); + + React.useEffect(() => { + if (user.id) { + dispatch( + apiActions.authUser(user.id).orders + .configure(action => action.path = ["auth", "lastOrders"]) + .all({ + order_by: "-id", + page_size: 5, + filter: { status__in: [1,2,3,4] }, + include: 'sale,orderlines,orderlines__item,orderlines__orderlineitems', + }) + ) + } + }, [dispatch, user.id]); return ( @@ -24,12 +41,12 @@ export default function Account(props) { - Mes commandes - - + Mes 5 dernières commandes + + + + Voir toutes mes commandes + diff --git a/src/pages/LoginLogout.jsx b/src/pages/LoginLogout.jsx index f7b29bb..7326ae2 100644 --- a/src/pages/LoginLogout.jsx +++ b/src/pages/LoginLogout.jsx @@ -1,17 +1,17 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Loader from '../components/common/Loader'; -import { API_URL } from '../constants'; +import React from "react"; +import PropTypes from "prop-types"; +import Loader from "components/common/Loader"; +import { API_URL } from "utils/constants"; export default function LoginLogout({ action }) { // Redirect to login/logout page - const callback = String(window.location).replace(/(.*)log(in|out)\/?$/, '$1'); + const callback = String(window.location).replace(/(.*)log(in|out)\/?$/, "$1"); window.location.replace(`${API_URL}/auth/${action}?redirect=${callback}`); return ; } LoginLogout.propTypes = { - action: PropTypes.oneOf(['login', 'logout']).isRequired, + action: PropTypes.oneOf(["login", "logout"]).isRequired, }; diff --git a/src/pages/admin/AdminNav.jsx b/src/pages/admin/AdminNav.jsx index f48e3e1..77950f2 100644 --- a/src/pages/admin/AdminNav.jsx +++ b/src/pages/admin/AdminNav.jsx @@ -50,7 +50,7 @@ function getMatch(location) { path: action.path, exact: true, strict: true, - }) + }); if (match) return { ...match, ...action }; } @@ -111,7 +111,7 @@ export default function AdminNav(props) { const classes = useStyles(); const match = getMatch(useLocation()); const resource = useSelector(store => ( - match && store.findData(match.resource, match.params && match.params.id) + match && store.api.findData(match.resource, match.params && match.params.id) )); const { title, actions } = getNavData(match, resource); diff --git a/src/pages/admin/AssoDashboard.jsx b/src/pages/admin/AssoDashboard.jsx index 34ad262..6649add 100644 --- a/src/pages/admin/AssoDashboard.jsx +++ b/src/pages/admin/AssoDashboard.jsx @@ -1,51 +1,56 @@ -import React from 'react' -import { useStoreAPIData } from '../../redux/hooks'; - -import { Container, Grid, Box } from '@material-ui/core' -import { Alert, AlertTitle } from '@material-ui/lab'; -import SalesList from '../../components/sales/SalesList'; -import DetailsTable from '../../components/common/DetailsTable'; - -export default function AssoDashboard(props) { - const assoId = props.match.params.asso_id; - const asso = useStoreAPIData(['associations', assoId]); - const sales = useStoreAPIData(['associations', assoId, 'sales']); - - return ( - - - - Informations - {asso && !asso.fun_id && ( - - - Configuration de l'association non complète - - L'attribut fun_id de l'association n'est pas défini. - Veuillez contacter un administrateur pour terminer la configuration. - - - - )} - - - - - Ventes - - - - - ); -} +import React from "react"; +import { useStoreAPIData } from "../../redux/hooks"; + +import { Container, Grid, Box } from "@material-ui/core"; +import { Alert, AlertTitle } from "@material-ui/lab"; +import SalesList from "../../components/sales/SalesList"; +import DetailsTable from "../../components/common/DetailsTable"; + +export default function AssoDashboard(props) { + const assoId = props.match.params.asso_id; + const asso = useStoreAPIData(["associations", assoId], null, { singleElement: true }); + const sales = useStoreAPIData(["associations", assoId, "sales"]); + + const { lastFetched, nbPages } = sales.pagination; + const fetchMore = (lastFetched < nbPages) ? () => sales.fetchData(lastFetched + 1) : null; + return ( + + + + Informations + {asso.fetched && asso.data && !asso.data.fun_id && ( + + + Configuration de l'association non complète + + L'attribut fun_id de l'association n'est pas défini. + Veuillez contacter un administrateur pour terminer la configuration. + + + + )} + + + + + Ventes + + + + + ); +} diff --git a/src/pages/admin/Dashboard.jsx b/src/pages/admin/Dashboard.jsx index 4155fc7..61ced38 100644 --- a/src/pages/admin/Dashboard.jsx +++ b/src/pages/admin/Dashboard.jsx @@ -1,18 +1,26 @@ -import React from 'react' -import actions from '../../redux/actions'; -import { useDispatch, useSelector } from 'react-redux'; -import { Container, Grid } from '@material-ui/core'; - -import AssoSalesList from '../../components/sales/AssoSalesList'; +import React from "react"; +import apiActions from "../../redux/actions/api"; +import { useDispatch, useSelector } from "react-redux"; +import { Container, Grid } from "@material-ui/core"; +import AssoSalesList from "../../components/sales/AssoSalesList"; export default function Dashboard(props) { const dispatch = useDispatch(); - const assos = useSelector(store => store.getAuthRelatedData('associations', {})); - const sales = useSelector(store => store.getResourceDataById('associations', 'sales', null)); + const assos = useSelector(store => store.api.getAuthRelatedData("associations")); + const sales = useSelector(store => { + if (!assos || !store.api.resources?.associations?.resources) + return {}; + + const assoResources = store.api.resources.associations.resources; + return Object.keys(assoResources).reduce((salesMap, assoId) => { + salesMap[assoId] = assoResources[assoId]?.resources?.sales; + return salesMap + }, {}); + }); - function handleFetchSales(assoId) { - dispatch(actions.associations(assoId).sales.all({ include_inactive: true })); + function fetchSales(assoId, page = 1) { + dispatch(apiActions.associations(assoId).sales.all({ page, page_size: 1, include_inactive: true })); } return ( @@ -28,7 +36,7 @@ export default function Dashboard(props) { diff --git a/src/pages/admin/SaleDetail/OrdersList.jsx b/src/pages/admin/SaleDetail/OrdersList.jsx deleted file mode 100644 index d5936fe..0000000 --- a/src/pages/admin/SaleDetail/OrdersList.jsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react'; -import MaterialTable from 'material-table'; -import { SkeletonTable } from '../../../components/common/Skeletons'; -import OrderLinesList from '../../../components/orders/OrderLinesList'; -import { useStoreAPIData } from '../../../redux/hooks'; -import { ORDER_STATUS, MaterialTableIcons } from '../../../constants'; -import { formatDate } from '../../../utils'; -import { parseISO } from 'date-fns' - - -const queryParams = { - include: 'owner,orderlines', -}; - -export default function OrdersList({ saleId, items, ...props }) { - const orders = useStoreAPIData(['sales', saleId, 'orders'], { queryParams }); - - if (!orders) - return ; - - // Get items names lookup - const ordersList = Object.values(orders).map(order => ({ - id: order.id, - owner: `${order.owner.first_name} ${order.owner.last_name}`, - updated_at: parseISO(order.updated_at), - status: ORDER_STATUS[order.status] || {}, - orderlines: order.orderlines, - })) - - // OrderLinesList - - return ( - ( - - {row.status.label || 'Inconnu'} - - ), - }, - { - title: 'Mise à jour', - field: 'updated_at', - searchable: false, - render: row => ( - {formatDate(row.updated_at, 'datetime')} - ), - }, - { - title: 'Articles', - field: 'orderlines', - searchable: false, - render: row => ( - - ), - }, - ]} - icons={MaterialTableIcons} - options={{ - search: true, - }} - - /> - ); -} diff --git a/src/pages/admin/SaleDetail/OrdersTable.jsx b/src/pages/admin/SaleDetail/OrdersTable.jsx new file mode 100644 index 0000000..b645e80 --- /dev/null +++ b/src/pages/admin/SaleDetail/OrdersTable.jsx @@ -0,0 +1,15 @@ +import React from "react"; +import Loader from "../../../components/common/Loader"; +import OrdersTable from "../../../components/orders/OrdersTable"; + +export default function SaleOrders({ saleId, ...props }) { + return ( + + + + ); +} diff --git a/src/pages/admin/SaleDetail/QuantitiesSold.jsx b/src/pages/admin/SaleDetail/QuantitiesSold.jsx index 45d4b15..a927534 100644 --- a/src/pages/admin/SaleDetail/QuantitiesSold.jsx +++ b/src/pages/admin/SaleDetail/QuantitiesSold.jsx @@ -1,105 +1,110 @@ -import React from 'react'; +import React from "react"; import { - Box, Grid, Collapse, Paper, IconButton, TableContainer, - Table, TableHead, TableBody, TableRow, TableCell, - } from '@material-ui/core' -import { Done, Pause, ExpandLess, ExpandMore } from '@material-ui/icons'; -import { Skeleton } from '@material-ui/lab'; -import { formatPrice } from '../../../utils'; - + Box, Grid, Collapse, Paper, IconButton, + TableContainer, Table, TableHead, TableBody, TableRow, TableCell, +} from "@material-ui/core"; +import { Done, Pause, ExpandLess, ExpandMore } from "@material-ui/icons"; +import { Skeleton } from "@material-ui/lab"; +import { formatPrice } from "utils/format"; +import { isEmpty } from "utils/helpers"; export function ItemsSold({ items, ...props }) { + if (!items) + return ; - if (!items) - return ; - - if (Object.values(items).length === 0) - return Pas d'article; + if (Object.values(items).length === 0) + return Pas d'article; - return ( - - - - - Actif - Item - Prix - Quantitées - - - - {Object.values(items).map(item => ( - - {item.is_active ? : } - {item.name} - {formatPrice(item.price)} - {item.quantity_sold || 0} / {item.quantity ? item.quantity : ∞} - - ))} - - - - ); + return ( + + + + + Actif + Item + Prix + Quantitées + + + + {Object.values(items).map((item) => ( + + {item.is_active ? : } + {item.name} + {formatPrice(item.price)} + + {item.quantity_sold || 0} + / + {item.quantity ? item.quantity : ∞} + + + ))} + + + + ); } export function GroupSold({ itemgroup, items, ...props }) { - const [open, setOpen] = React.useState(true); + const [open, setOpen] = React.useState(true); - const totalSold = items ? items.reduce((sum, item) => sum + item.quantity_sold, 0) : '...'; - return ( - - - - - {itemgroup ? itemgroup.name : '...'} - - - {totalSold || 0} - setOpen(!open)}> - {open ? : } - - - - - - - - - - - ); + const totalSold = items + ? items.reduce((sum, item) => sum + item.quantity_sold, 0) + : "..."; + return ( + + + + + + {itemgroup ? itemgroup.name : "..."} + + + + {totalSold || 0} + setOpen(!open)}> + {open ? : } + + + + + + + + + + + ); } -export default function QuantitiesSold({ items, itemgroups, ...props }) { +export default function QuantitiesSold({ items, itemgroups, fetched, ...props }) { + if (!fetched) + return ; - if (!items || !itemgroups) - return ; + // TODO Better empty state + if (isEmpty(items)) + return Aucun article; - const itemsByGroup = Object.values(items).reduce((groupMap, { group, ...item }) => { - if (group in groupMap) - groupMap[group].push(item); - else - groupMap[group] = [ item ]; - return groupMap; - }, {}); + const itemsByGroup = Object.values(items).reduce((groupMap, { group, ...item }) => { + if (group in groupMap) groupMap[group].push(item); + else groupMap[group] = [item]; + return groupMap; + }, {}); - const orphans = itemsByGroup[null]; - return ( - - {Object.values(itemgroups).map(itemgroup => ( - - ))} - {orphans && orphans.length && ( - - )} - - ); + const orphans = itemsByGroup[null]; + return ( + + {Object.values(itemgroups).map((itemgroup) => ( + + ))} + {orphans && orphans.length && ( + + )} + + ); } diff --git a/src/pages/admin/SaleDetail/TicketsList.jsx b/src/pages/admin/SaleDetail/TicketsList.jsx index 5b7e704..4ba00f7 100644 --- a/src/pages/admin/SaleDetail/TicketsList.jsx +++ b/src/pages/admin/SaleDetail/TicketsList.jsx @@ -1,70 +1,75 @@ -import React from 'react'; -import MaterialTable from 'material-table'; -import { SkeletonTable } from '../../../components/common/Skeletons'; -import { useStoreAPIData } from '../../../redux/hooks'; -import { VALID_ORDER_STATUS, MaterialTableIcons } from '../../../constants'; -import { arrayToMap } from '../../../utils'; +import React from "react"; +import { useStoreAPIData } from "redux/hooks"; +import APIDataTable from "components/common/APIDataTable"; +import { VALID_ORDER_STATUS } from "utils/constants"; +import { arrayToMap } from "utils/helpers"; +export default function TicketsList({ saleId, items, itemgroups, ...props }) { + // Get all fields for the moment, shouldn't be too much + const fields = useStoreAPIData("fields", { page_size: "max" }).data; -const queryParams = { - filter: `order__status__in=${VALID_ORDER_STATUS.join(',')}`, - include: 'orderlineitems__orderlinefields', -}; + // Get items and groups names lookups + const itemNames = items ? arrayToMap(Object.values(items), "id", "name") : {}; + const itemgroupNames = items && itemgroups ? Object.values(items).reduce((acc, item) => { + if (item.group) + acc[item.id] = (itemgroups[item.group] || {}).name || "Inconnu"; + return acc; + }, {}) : {}; -export default function TicketsList({ saleId, items, ...props }) { + const fieldsColumns = ( + items && fields + ? Object.values(items).reduce((map, item) => { + item.fields.forEach((field) => { + if (!map.hasOwnProperty(field)) + map[field] = { + field: field, + title: fields[field] ? fields[field].name : field, + }; + }); + return map; + }, {}) + : {} + ); - // Get all fields for the moment, shouldn't be too much - const fields = useStoreAPIData('fields'); - const orderlines = useStoreAPIData(['sales', saleId, 'orderlines'], { queryParams }); + // Patch data for table display + function reduceOrderlines(orderlines) { + return orderlines.reduce((arr, orderline) => { + arr.push( + ...orderline.orderlineitems.map((orderlineitem) => ({ + ...orderlineitem, + item: orderline.item, + ...orderlineitem.orderlinefields.reduce((fields, orderlinefield) => { + fields[orderlinefield.field] = orderlinefield.value; + return fields; + }, {}), + })) + ); + return arr; + }, []); + } - if (!orderlines) - return ; - - - // Get items names lookup - const itemNames = items ? arrayToMap(Object.values(items), 'id', 'name') : {}; - - // Get fields columns - const fieldsColumns = items ? Object.values(items).reduce((map, item) => { - item.fields.forEach(field => { - if (!map.hasOwnProperty(field)) - map[field] = { - field: field, - title: fields[field] ? fields[field].name : field, - }; - }) - return map; - }, {}) : {}; - - // Patch data for table - const orderlineitems = Object.values(orderlines).reduce((arr, orderline) => { - arr.push(...orderline.orderlineitems.map(orderlineitem => ({ - ...orderlineitem, - item: orderline.item, - ...orderlineitem.orderlinefields.reduce((fields, orderlinefield) => { - fields[orderlinefield.field] = orderlinefield.value; - return fields; - }, {}), - }))); - return arr; - }, []); - - return ( - - ); + return ( + + ); } diff --git a/src/pages/admin/SaleDetail/index.jsx b/src/pages/admin/SaleDetail/index.jsx index 6a93e6f..81fd7df 100644 --- a/src/pages/admin/SaleDetail/index.jsx +++ b/src/pages/admin/SaleDetail/index.jsx @@ -1,31 +1,32 @@ -import React from 'react'; -import { useStoreAPIData } from '../../../redux/hooks'; -import { formatDate } from '../../../utils'; +import React from "react"; +import { useStoreAPIData } from "redux/hooks"; +import { formatDate } from "utils/format"; -import { Box, Container, Grid, Chip, Tabs, Tab } from '@material-ui/core'; -import { PlayArrow, Pause, Public, Lock } from '@material-ui/icons'; +import { Box, Container, Grid, Chip, Tabs, Tab } from "@material-ui/core"; +import { PlayArrow, Pause, Public, Lock } from "@material-ui/icons"; -import Stat from '../../../components/common/Stat'; -import { Link } from '../../../components/common/Nav'; -import { CopyButton } from '../../../components/common/Buttons'; +import Stat from "components/common/Stat"; +import { Link } from "components/common/Nav"; +import { CopyButton } from "components/common/Buttons"; -import QuantitiesSold from './QuantitiesSold'; -import OrdersList from './OrdersList'; -import TicketsList from './TicketsList'; +import QuantitiesSold from "./QuantitiesSold"; +import OrdersTable from "./OrdersTable"; +import TicketsList from "./TicketsList"; export default function SaleDetail(props) { - const [tab, setTab] = React.useState('quantities'); + const [tab, setTab] = React.useState("quantities"); const saleId = props.match.params.sale_id; - const sale = useStoreAPIData(['sales', saleId], { queryParams: { include: 'association' } }); - const items = useStoreAPIData(['sales', saleId, 'items']); - const itemgroups = useStoreAPIData(['sales', saleId, 'itemgroups']); + const { data: sale, fetched } = useStoreAPIData(["sales", saleId], { include: "association" }, { singleElement: true }); + const items = useStoreAPIData(["sales", saleId, "items"], { page_size: 'max' }); + const itemgroups = useStoreAPIData(["sales", saleId, "itemgroups"], { page_size: 'max' }); - if (!sale) + // TODO Better loader + if (!fetched) return "Loading" - const saleLink = window.location.href.replace('/admin/', '/'); + const saleLink = window.location.href.replace("/admin/", "/"); const chipMargin = { marginBottom: 4, marginRight: 4 }; return ( @@ -48,8 +49,8 @@ export default function SaleDetail(props) { Dates - Ouverture: {sale.begin_at ? formatDate(sale.begin_at, 'datetime') : "Inconnue"} - Fermeture: {sale.end_at ? formatDate(sale.end_at, 'datetime') : "Inconnue"} + Ouverture: {sale.begin_at ? formatDate(sale.begin_at, "datetime") : "Inconnue"} + Fermeture: {sale.end_at ? formatDate(sale.end_at, "datetime") : "Inconnue"} @@ -78,28 +79,31 @@ export default function SaleDetail(props) { - {(tab === 'quantities' && ( + {(tab === "quantities" && ( - )) || (tab === 'orders' && ( - - )) || (tab === 'tickets' && ( + )) || (tab === "tickets" && ( - )) || (tab === 'chart' && ( + )) || (tab === "chart" && ( À venir... ))} diff --git a/src/pages/admin/SaleEditor/DetailsEditor.jsx b/src/pages/admin/SaleEditor/DetailsEditor.jsx index 3489cd9..8266550 100644 --- a/src/pages/admin/SaleEditor/DetailsEditor.jsx +++ b/src/pages/admin/SaleEditor/DetailsEditor.jsx @@ -1,9 +1,9 @@ import React from 'react'; import { Box, Paper, Grid, Button } from '@material-ui/core'; -import { mergeClasses, useFormStyles } from '../../../styles'; -import { LoadingButton } from '../../../components/common/Buttons'; -import FieldGenerator from '../../../components/common/FieldGenerator'; +import { LoadingButton } from 'components/common/Buttons'; +import FieldGenerator from 'components/common/FieldGenerator'; +import { mergeClasses, useFormStyles } from 'utils/styles'; export default function DetailsEditor({ disabled, editing, isCreator, ...props }) { disabled = disabled || props.saving; @@ -20,12 +20,6 @@ export default function DetailsEditor({ disabled, editing, isCreator, ...props } {Field.text('id', 'ID', onlyCreate)} {Field.select('association', 'Association', props.assos, onlyCreate)} {Field.text('description', 'Description', { required: true, multiline: true, rows: 4 })} - -{/* - {Field.color('color', 'QCouleuuur')} -*/} - - diff --git a/src/pages/admin/SaleEditor/ItemsManager/GroupBlock.jsx b/src/pages/admin/SaleEditor/ItemsManager/GroupBlock.jsx index 26433fe..df58692 100644 --- a/src/pages/admin/SaleEditor/ItemsManager/GroupBlock.jsx +++ b/src/pages/admin/SaleEditor/ItemsManager/GroupBlock.jsx @@ -3,7 +3,7 @@ import { makeStyles } from '@material-ui/core/styles'; import { Box, Grid } from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; -import { isEmpty } from '../../../../utils'; +import { isEmpty } from 'utils/helpers'; import NoItems from './NoItems'; import ItemCard from './ItemCard'; diff --git a/src/pages/admin/SaleEditor/ItemsManager/ItemCard.jsx b/src/pages/admin/SaleEditor/ItemsManager/ItemCard.jsx index 5d622fb..e5c54c2 100644 --- a/src/pages/admin/SaleEditor/ItemsManager/ItemCard.jsx +++ b/src/pages/admin/SaleEditor/ItemsManager/ItemCard.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { Card, CardContent } from '@material-ui/core'; -import { formatPrice } from '../../../../utils'; +import { formatPrice } from 'utils/format'; function displayQuantity(quantity, yesText, noText = null) { if (quantity) diff --git a/src/pages/admin/SaleEditor/ItemsManager/ItemEditor.jsx b/src/pages/admin/SaleEditor/ItemsManager/ItemEditor.jsx index c40479b..774e4f5 100644 --- a/src/pages/admin/SaleEditor/ItemsManager/ItemEditor.jsx +++ b/src/pages/admin/SaleEditor/ItemsManager/ItemEditor.jsx @@ -1,109 +1,110 @@ -import React from 'react'; -import { - Box, Grid, Button, TableContainer, Table, - TableHead, TableBody, TableRow, TableCell -} from '@material-ui/core'; - - -import FieldGenerator from '../../../../components/common/FieldGenerator'; -import { useFormStyles } from '../../../../styles'; - -function ItemFieldsTable({ Field, itemfields, fields, ...props}) { - return ( - - - - - Champ - Editable - - - Ajouter - - - - - - {itemfields.length ? ( - itemfields.map((itemfield, index) => ( - - - {(itemfield._isNew - ? Field.select(`itemfields.${index}.field`, null, fields, { required: true }) - : fields[itemfield.field].label - )} - - - {Field.boolean(`itemfields.${index}.editable`)} - - - - Supprimer - - - - )) - ) : ( - - - Aucun champ ! Ajouter en avec le bouton ci-dessus. - - - )} - - - - ); -} - -export default function ItemEditor({ item, ...props }) { - const classes = useFormStyles() - const Field = new FieldGenerator( - item, - props.errors, - props.onChange, - `items.${item.id}`, - { disabled: props.disabled || props.saving } - ); - - return ( - - - - Description - {Field.text('name', "Nom", { autoFocus: true })} - {Field.text('description', "Description", { multiline: true, rows: 2 })} - {Field.select('group', "Groupe", props.itemgroups, { default: 'null' })} - {Field.select('usertype', "Type d'acheteur", props.usertypes)} - {Field.number('price', "Prix")} - - - - Disponibilité - {Field.boolean('is_active', "Actif")} - {Field.number('quantity', "Quantité")} - {Field.number('max_per_user', "Max par acheteur")} - - - - {!item._isNew && ( - - Champs - - - )} - - ); -} +import React from 'react'; +import { + Box, Grid, Button, TableContainer, Table, + TableHead, TableBody, TableRow, TableCell +} from '@material-ui/core'; + + +import FieldGenerator from 'components/common/FieldGenerator'; +import { useFormStyles } from 'utils/styles'; + + +function ItemFieldsTable({ Field, itemfields, fields, ...props}) { + return ( + + + + + Champ + Editable + + + Ajouter + + + + + + {itemfields.length ? ( + itemfields.map((itemfield, index) => ( + + + {(itemfield._isNew + ? Field.select(`itemfields.${index}.field`, null, fields, { required: true }) + : fields[itemfield.field].label + )} + + + {Field.boolean(`itemfields.${index}.editable`)} + + + + Supprimer + + + + )) + ) : ( + + + Aucun champ ! Ajouter en avec le bouton ci-dessus. + + + )} + + + + ); +} + +export default function ItemEditor({ item, ...props }) { + const classes = useFormStyles() + const Field = new FieldGenerator( + item, + props.errors, + props.onChange, + `items.${item.id}`, + { disabled: props.disabled || props.saving } + ); + + return ( + + + + Description + {Field.text('name', "Nom", { autoFocus: true })} + {Field.text('description', "Description", { multiline: true, rows: 2 })} + {Field.select('group', "Groupe", props.itemgroups, { default: 'null' })} + {Field.select('usertype', "Type d'acheteur", props.usertypes)} + {Field.number('price', "Prix")} + + + + Disponibilité + {Field.boolean('is_active', "Actif")} + {Field.number('quantity', "Quantité")} + {Field.number('max_per_user', "Max par acheteur")} + + + + {!item._isNew && ( + + Champs + + + )} + + ); +} diff --git a/src/pages/admin/SaleEditor/ItemsManager/ItemGroupEditor.jsx b/src/pages/admin/SaleEditor/ItemsManager/ItemGroupEditor.jsx index 420631e..31fcf28 100644 --- a/src/pages/admin/SaleEditor/ItemsManager/ItemGroupEditor.jsx +++ b/src/pages/admin/SaleEditor/ItemsManager/ItemGroupEditor.jsx @@ -1,7 +1,7 @@ import React from 'react'; -import FieldGenerator from '../../../../components/common/FieldGenerator'; -import { useFormStyles } from '../../../../styles'; +import FieldGenerator from 'components/common/FieldGenerator'; +import { useFormStyles } from 'utils/styles'; export default function ItemGroupEditor({ itemgroup, ...props }) { diff --git a/src/pages/admin/SaleEditor/ItemsManager/ItemsDisplay.jsx b/src/pages/admin/SaleEditor/ItemsManager/ItemsDisplay.jsx index b46d49c..cb977bd 100644 --- a/src/pages/admin/SaleEditor/ItemsManager/ItemsDisplay.jsx +++ b/src/pages/admin/SaleEditor/ItemsManager/ItemsDisplay.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box } from '@material-ui/core'; -import { isEmpty } from '../../../../utils'; +import { isEmpty } from 'utils/helpers'; import NoItems from './NoItems'; import GroupBlock from './GroupBlock'; @@ -17,7 +17,7 @@ export default function ItemsDisplay({ itemgroups, ...props }) { return ( {Object.values(itemgroups).map(itemgroup => ( - ); - + // Wrap editor with edition buttons const buttonProps = { name, value: id, disabled: editorProps.saving }; editor = ( @@ -104,7 +104,7 @@ function ItemsManager({ selected, ...props }) { {!isNew && ( Annuler @@ -165,7 +165,7 @@ function ItemsManager({ selected, ...props }) { color="primary" variant="outlined" > Ajouter un groupe - + { const saleId = props.match.params.sale_id || null; - const assos = store.getAuthRelatedData('associations', {}); - const usertypes = store.get('usertypes'); - const itemgroups = saleId ? store.getData(['sales', saleId, 'itemgroups'], {}) : {}; - const fields = store.get('fields'); + const assos = store.api.getAuthRelatedData('associations', {}); + const usertypes = store.api.get('usertypes'); + const itemgroups = saleId ? store.api.getData(['sales', saleId, 'itemgroups'], {}) : {}; + const fields = store.api.get('fields'); return { saleId, - sale: saleId ? store.getData(['sales', saleId], null) : null, - items: saleId ? store.getData(['sales', saleId, 'items'], {}) : {}, + sale: saleId ? store.api.getData(['sales', saleId], null) : null, + items: saleId ? store.api.getData(['sales', saleId, 'items'], {}) : {}, itemgroups, itemgroupsChoices: { ...dataToChoices(itemgroups, 'name'), null: { label: 'Sans groupe', value: 'null' } }, @@ -60,9 +64,9 @@ class SaleEditor extends React.Component { // Fetch side resources if (!this.props.usertypes.fetched) - this.props.dispatch(actions.usertypes.all()); + this.props.dispatch(apiActions.usertypes.all()); if (!this.props.fields.fetched) - this.props.dispatch(actions.fields.all()); + this.props.dispatch(apiActions.fields.all()); } componentDidUpdate(prevProps) { @@ -93,9 +97,9 @@ class SaleEditor extends React.Component { loading_itemgroups: true, }); const saleId = this.props.saleId; - this.props.dispatch(actions.sales.find(saleId)); - this.props.dispatch(actions.sales(saleId).items.all({ include: 'itemfields' })); - this.props.dispatch(actions.sales(saleId).itemgroups.all()); + this.props.dispatch(apiActions.sales.find(saleId, QUERY_PARAMS.sale)); + this.props.dispatch(apiActions.sales(saleId).items.all(QUERY_PARAMS.items)); + this.props.dispatch(apiActions.sales(saleId).itemgroups.all(QUERY_PARAMS.itemgroups)); } getStateFor(resource, prevState = {}) { @@ -194,9 +198,9 @@ class SaleEditor extends React.Component { // Save changes and return a Promise for all calls // TODO Get and update items from items(itemId).itemfields return Promise.all([ - ...changes.to_create.map(data => actions.itemfields.create(null, data)), - ...changes.to_update.map(([id, data]) => actions.itemfields.update(id, null, data)), - ...changes.to_delete.map(id => actions.itemfields.delete(id)), + ...changes.to_create.map(data => apiActions.itemfields.create(null, data)), + ...changes.to_update.map(([id, data]) => apiActions.itemfields.update(id, null, data)), + ...changes.to_delete.map(id => apiActions.itemfields.delete(id)), ].map(action => action.payload)); } @@ -253,7 +257,7 @@ class SaleEditor extends React.Component { const { _editing, ...details } = this.state.details; // Check values - if (!REGEX_SLUG.test(details.id)) { + if (!SLUG_REGEX.test(details.id)) { return this.setState(prevState => produce(prevState, draft => { draft.errors.details.id = ["Invalide"]; return draft; @@ -264,14 +268,14 @@ class SaleEditor extends React.Component { try { if (this.isCreator()) { // Create sale - const action = actions.sales.create(null, details); + const action = apiActions.sales.create(QUERY_PARAMS.sale, details); const response = await action.payload; // Dispatch creation and go to edit mode this.props.dispatch(action); this.props.history.push(`/admin/sales/${response.data.id}/edit`); } else { // Update sale details - const action = actions.sales.update(this.props.saleId, null, details); + const action = apiActions.sales.update(this.props.saleId, QUERY_PARAMS.sale, details); await action.payload; this.props.dispatch(action); } @@ -328,7 +332,7 @@ class SaleEditor extends React.Component { data.sale = saleId; // Create resource - const action = actions.sales(saleId)[resource].create(null, data); + const action = apiActions.sales(saleId)[resource].create(QUERY_PARAMS[resource], data); await action.payload; // Creation succeeded, remove fake id and dispatch created @@ -347,8 +351,7 @@ class SaleEditor extends React.Component { await this._saveItemFields(data); // Update resource and wait for feedback to dispatch - const queryParams = resource === 'items' ? { include: 'itemfields' } : null; - const action = actions[resource].update(id, queryParams, data) + const action = apiActions[resource].update(id, QUERY_PARAMS[resource], data) await action.payload; this.props.dispatch(action); @@ -380,7 +383,7 @@ class SaleEditor extends React.Component { draft.selected = null; delete draft[resource][id]; return draft; - }), () => isNew || this.props.dispatch(actions[resource].delete(id))); + }), () => isNew || this.props.dispatch(apiActions[resource].delete(id))); } handleResetResource = event => { diff --git a/src/pages/admin/index.jsx b/src/pages/admin/index.jsx index ce98dd0..8cb13b3 100644 --- a/src/pages/admin/index.jsx +++ b/src/pages/admin/index.jsx @@ -1,22 +1,22 @@ import React from 'react'; import { Switch, Route, Redirect } from 'react-router-dom'; import { useSelector } from 'react-redux'; -import { hasManagerRights } from '../../utils'; +import { hasManagerRights } from 'utils/api'; -import Loader from '../../components/common/Loader'; -import AdminNav from './AdminNav'; -import Dashboard from './Dashboard'; -import AssoDashboard from './AssoDashboard'; -import SaleView from './SaleView'; -import SaleDetail from './SaleDetail/'; -import SaleEditor from './SaleEditor/'; -import Error404 from '../Error404'; +import Loader from 'components/common/Loader'; +import AdminNav from 'pages/admin/AdminNav'; +import Dashboard from 'pages/admin/Dashboard'; +import AssoDashboard from 'pages/admin/AssoDashboard'; +import SaleView from 'pages/admin/SaleView'; +import SaleDetail from 'pages/admin/SaleDetail/index'; +import SaleEditor from 'pages/admin/SaleEditor/index'; +import Error404 from 'pages/Error404'; export default function AdminSite(props) { // Get data from store - const auth = useSelector(store => store.getData('auth')); - const userAssos = useSelector(store => store.getAuthRelatedData('associations', null)); + const auth = useSelector(store => store.api.getData('auth')); + const userAssos = useSelector(store => store.api.getAuthRelatedData('associations', null)); // Wait for user's associations to be fetched if (userAssos === null) diff --git a/src/pages/public/ContactForm.js b/src/pages/public/ContactForm.js index 3836b9e..a557815 100644 --- a/src/pages/public/ContactForm.js +++ b/src/pages/public/ContactForm.js @@ -1,3 +1,4 @@ +<<<<<<< HEAD import React, {useState} from 'react' import { useFormStyles} from "../../styles"; import {Box, Container, Grid, Paper} from "@material-ui/core"; @@ -8,13 +9,29 @@ import {apiAxios, messagesActions} from "../../redux/actions"; import Loader from "../../components/common/Loader"; import FieldGenerator from '../../components/common/FieldGenerator'; import {useDispatch} from "react-redux"; +======= +import React from 'react' +import {useDispatch, useSelector} from "react-redux"; +import messagesActions from "redux/actions/messages"; +import {apiAxios} from "utils/api"; +import {useFormStyles} from "utils/styles"; + +import {Box, Container, Grid, Paper, Button} from "@material-ui/core"; +import CloudUploadIcon from '@material-ui/icons/CloudUpload'; +import Loader from "components/common/Loader"; +import FieldGenerator from 'components/common/FieldGenerator'; +>>>>>>> ce994cff935be9d282d472d27815a06f771b0c9e export default function ContactForm() { const error = {}; const initialStore = {choix: 0, reason: null, message: null, loading: false} +<<<<<<< HEAD const [store, changeStore] = useState(initialStore); +======= + const [store, changeStore] = React.useState(initialStore); +>>>>>>> ce994cff935be9d282d472d27815a06f771b0c9e const classes = useFormStyles(); const onChange = event => { @@ -25,6 +42,7 @@ export default function ContactForm() { changeStore(prevState => ({...prevState, [name]: value})); } +<<<<<<< HEAD const Field = new FieldGenerator( store, error, @@ -32,6 +50,9 @@ export default function ContactForm() { null, null ); +======= + const Field = new FieldGenerator(store, error, onChange); +>>>>>>> ce994cff935be9d282d472d27815a06f771b0c9e const choicePossibilites = [ { diff --git a/src/pages/public/OrderDetail.jsx b/src/pages/public/OrderDetail.jsx index a6b5b04..22a0b71 100644 --- a/src/pages/public/OrderDetail.jsx +++ b/src/pages/public/OrderDetail.jsx @@ -1,16 +1,19 @@ import React from 'react'; import produce from 'immer'; import { connect } from 'react-redux'; -import actions, { apiAxios } from '../../redux/actions'; -import { API_URL, ORDER_STATUS, STATUS_MESSAGES } from '../../constants'; -import { arrayToMap } from '../../utils'; +import apiActions from 'redux/actions/api'; +import { API_URL, ORDER_STATUS, STATUS_MESSAGES } from 'utils/constants'; +import { apiAxios, updateOrderStatus } from 'utils/api'; +import { arrayToMap } from 'utils/helpers'; import { withStyles } from '@material-ui/core/styles'; -import { Box, Container, Grid, Button, Chip, CircularProgress } from '@material-ui/core'; +import { Box, Container, Grid, Button } from '@material-ui/core'; import { Alert, AlertTitle } from '@material-ui/lab'; -import { LoadingButton } from '../../components/common/Buttons'; -import { NavButton } from '../../components/common/Nav'; -import OrderLineItemTicket from '../../components/orders/OrderLineItemTicket'; + +import { NavButton } from 'components/common/Nav'; +import { LoadingButton } from 'components/common/Buttons'; +import { OrderStatusButton } from 'components/orders/OrderButtons'; +import OrderLineItemTicket from 'components/orders/OrderLineItemTicket'; const INCLUDE_QUERY = [ @@ -39,7 +42,7 @@ const connector = connect((store, props) => { const orderId = props.match.params.order_id; return { orderId, - order: store.getData(['orders', orderId], null, true), + order: store.api.getData(['orders', orderId], null, true), }; }); @@ -80,17 +83,17 @@ class OrderDetail extends React.Component { }) fetchOrder = () => this.props.dispatch( - actions.orders.find(this.props.orderId, { include: INCLUDE_QUERY }) + apiActions.orders.find(this.props.orderId, { include: INCLUDE_QUERY }) ) /** Fetch status and redirect to payment or refresh order */ updateStatus = () => { this.setState({ updatingStatus: true }, async () => { - const resp = (await apiAxios.get(`/orders/${this.props.orderId}/status`)).data - if (resp.redirect_url) - window.location.href = resp.redirect_url; + const resp = await updateOrderStatus(this.props.dispatch, this.props.orderId); + if (resp.data.redirect_url) + resp.redirectToPayment(); else - this.setState({ updatingStatus: false }, resp.updated ? this.fetchOrder : null); + this.setState({ updatingStatus: false }, resp.data.updated ? this.fetchOrder : null); }); } @@ -131,6 +134,7 @@ class OrderDetail extends React.Component { render() { const { classes, order } = this.props; + // TODO Better loading if (!order) return "Loading" const { orderlineitems, saving, changing, updatingStatus } = this.state; @@ -140,12 +144,11 @@ class OrderDetail extends React.Component { Commande n°{order.id} - : null} - clickable + @@ -196,7 +199,7 @@ class OrderDetail extends React.Component { - Mes commandes - - - - - - - - ); -} diff --git a/src/pages/public/SaleDetail.jsx b/src/pages/public/SaleDetail.jsx index 85cffce..858e8d6 100644 --- a/src/pages/public/SaleDetail.jsx +++ b/src/pages/public/SaleDetail.jsx @@ -17,366 +17,369 @@ import {ShoppingCart, Delete} from '@material-ui/icons'; import {Alert, AlertTitle} from '@material-ui/lab'; import LinearProgress from "@material-ui/core/LinearProgress"; import Typography from "@material-ui/core/Typography"; +import { DATA_SCOPES } from 'redux/constants'; +import apiActions from 'redux/actions/api'; +import { differenceInSeconds } from 'date-fns' +import { getSaleState } from 'utils/api'; + + + + +const COUNTDOWN_MAX = 12 * 3600; // Countdown 12h before const connector = connect((store, props) => { - const saleId = props.match.params.sale_id; - return { - saleId, - authenticated: Boolean(store.getData('auth', {}).authenticated), - sale: store.findData('sales', saleId, 'id'), - order: store.getData(['sales', saleId, 'userOrder'], null, true), - items: store.getData(['sales', saleId, 'items']), - }; -}); -function LinearProgressWithLabel(props) { - return ( - - {props.value ? ( - - - - - - ) : ( - - - - - ) - } - - - {props.text} - - - - ); -} + const saleId = props.match.params.sale_id; + return { + saleId, + authenticated: Boolean(store.api.getData('auth', {}).authenticated), + sale: store.api.findData('sales', saleId), + order: store.api.getData(['sales', saleId, 'userOrder'], null, true), + items: store.api.getData(['sales', saleId, 'items']), + }; +}); -class SaleDetail extends React.Component { - - state = { - quantities: {}, - buying: false, - cgvAccepted: false, - progress: 0, - timeLeft: "Chargement..." - } - - componentDidMount() { - const saleId = this.props.saleId; - if (this.props.authenticated && !this.props.order) - this.fetchOrder(); - - if (!this.props.sale) - this.props.dispatch(actions.sales.find(saleId, {include: 'association'})); - - if (!this.props.items) - this.props.dispatch(actions.sales(saleId).items.all()); - - this.interval = setInterval(() => { - if (document.getElementById(this.props.sale.id)) { - const countdown = getCountdown(this.props.sale.begin_at); - if (!countdown.timer) - window.location.reload(false); - - this.setState(prevState => ({...prevState, progress: countdown.nbSeconds, timeLeft: countdown.timer})) - } - }, 1000); - } - - componentDidUpdate(prevProps) { - const order = this.props.order; - - // Update quantities from current order - if (prevProps.order !== order && order && order.orderlines.length) { - this.setState({ - quantities: order.orderlines.reduce((acc, orderline) => { - acc[orderline.item] = orderline.quantity; - return acc; - }, {}) - }); - } - } - - componentWillUnmount() { - clearInterval(this.interval); - } - - // ----------------------------------------------------- - // Order handlers - // ----------------------------------------------------- - - fetchOrder = () => { - const saleId = this.props.saleId; - this.props.dispatch( - actions.sales(saleId).orders - .definePath(['sales', saleId, 'userOrder']) - .setOptions({meta: {action: 'updateAll'}}) - .create({include: 'orderlines'}) - ); - } - - /** Sabe order on the server */ - saveOrder = (event, notif = true, update = false) => { - if (!this.props.order) { - console.warn("No order") - return; - } - - // Save all orderlines - const order = this.props.order.id; - const options = {withCredentials: true}; - const promises = Promise.all( - Object.entries(this.state.quantities).reduce((calls, [item, quantity]) => { - const data = {order, item, quantity}; - calls.push(apiAxios.post('orderlines', data, options)); - return calls; - }, []) - ); - - if (notif) // TODO - promises.then(resp => console.log('Saved')); - if (update) - promises.then(this.fetchOrder); - return promises - } - - /** Redirect to payment */ - payOrder = async event => { - const orderId = this.props.order.id; - const returnUrl = window.location.href.replace(this.props.location.pathname, `/orders/${orderId}`); - try { - const resp = await apiAxios.get(`/orders/${orderId}/pay?return_url=${returnUrl}`, {withCredentials: true}); - window.location.href = resp.data['redirect_url']; - } catch (error) { - this.props.dispatch(messagesActions.pushError(error, "Erreur avec votre commande")); - this.fetchOrder(); - } - } - - /** Cancel an order */ - cancelOrder = event => { - actions.orders.delete(this.props.order.id).payload.finally(this.fetchOrder); - } - - /** Save order and redirect to payment */ - handleBuy = event => { - if (this.canBuy()) { - this.setState({buying: true}, async () => { - await this.saveOrder(); - await this.payOrder(); - }); - } - } - - // ----------------------------------------------------- - // Event handlers - // ----------------------------------------------------- - - toggleCGV = event => this.setState(prevState => ({cgvAccepted: !prevState.cgvAccepted})) - - handleQuantityChange = event => { - const id = Number(event.currentTarget.dataset.itemId); - const value = Number(event.currentTarget.value); - this.setState(prevState => ({ - quantities: { - ...prevState.quantities, - [id]: value, - }, - })); - } - - handleReset = event => this.setState({quantities: {}}) - - // ----------------------------------------------------- - // Display - // ----------------------------------------------------- - - currentSaleState = () => { - const sale = this.props.sale; - if (!sale) - return null; - if (sale.end_at && isPast(new Date(sale.end_at))) - return 'FINISHED'; - if (sale.begin_at && isPast(new Date(sale.begin_at))) - return 'ONGOING'; - return 'NOT_BEGUN'; - } - - hasUnpaidOrder = () => Boolean(this.props.order && this.props.order.status === 3) - - areItemsDisabled = () => Boolean(!this.props.authenticated || this.hasUnpaidOrder()) - - hasItemsInCart = () => Object.values(this.state.quantities).some(qt => qt > 0) - - canBuy = () => ( - this.props.authenticated - && this.state.cgvAccepted - && this.currentSaleState() === 'ONGOING' - && this.hasItemsInCart() - ) - - - render() { - const {classes, sale} = this.props; - const {cgvAccepted} = this.state; - if (!sale || this.props.fetchingSale) - return - - const saleState = this.currentSaleState() - const CGVLink = props => - return ( - - - - {sale.name} - Par {sale.association.shortname} - - - - - - - Description - {sale.description} - - Liens - - Conditions Générales de Ventes - - - Dates - - Ouverture: {sale.begin_at ? formatDate(sale.begin_at) : "Inconnue"} - Fermeture: {sale.end_at ? formatDate(sale.end_at) : "Inconnue"} - - - - - Articles en vente - - {saleState === 'FINISHED' && ( - - La vente est terminée - +class SaleDetail extends React.Component{ + + state = { + quantities: {}, + buying: false, + cgvAccepted: false, + countdown: undefined, + // progress: 0, + // timeLeft: "Chargement...", + saleState: null, + } + + componentDidMount() { + const saleId = this.props.saleId; + if (this.props.authenticated) + this.fetchOrder(); + + if (!this.props.sale?.association?.id) + this.props.dispatch(apiActions.sales.find(saleId, { include: "association" })); + + if (!this.props.items) + this.props.dispatch(apiActions.sales(saleId).items.all()); + + if (this.props.sale) + this.updateSaleState(); + } + + componentDidUpdate(prevProps) { + if (prevProps.sale !== this.props.sale) + this.updateSaleState(); + + // Update quantities from current order + const order = this.props.order; + if (prevProps.order !== order && order?.orderlines?.length) { + this.setState({ + quantities: order.orderlines.reduce((acc, orderline) => { + acc[orderline.item] = orderline.quantity; + return acc; + }, {}) + }); + } + } + + componentWillUnmount() { + if (this.interval) + clearInterval(this.interval); + } + + updateSaleState = () => { + if (this.interval) + clearInterval(this.interval); + + const saleState = getSaleState(this.props.sale); + this.setState({ saleState, countdown: undefined }); + + // Start countdown if not begun and less than 24 hours to go + const start = new Date(this.props.sale.begin_at); + if (saleState?.key === 'NOT_BEGUN' && differenceInSeconds(start, new Date()) < COUNTDOWN_MAX) { + this.interval = setInterval(() => { + const secondsLeft = differenceInSeconds(start, new Date()); + const countdown = (COUNTDOWN_MAX - secondsLeft) / COUNTDOWN_MAX * 100; + if (secondsLeft <= 0) + this.updateSaleState(); + else + this.setState(prevState => ({ ...prevState, countdown })); + }, 1000); + } + } + + // ----------------------------------------------------- + // Order handlers + // ----------------------------------------------------- + + getOrderAction = () => ( + apiActions + .sales(this.props.saleId).orders() + .configure(action => { + action.path = ['sales', this.props.saleId, 'userOrder' ]; + action.idIsGiven = false; + action.options.meta = { + dataScope: DATA_SCOPES.FULL, + }; + }) + ) + + fetchOrder = () => { + // Try to find a previous ongoing order + this.getOrderAction().get({ filter: "status=0" }).payload.then(resp => { + if (resp.data.results.length) { + const orderId = resp.data.results[0].id + this.props.dispatch(this.getOrderAction().find(orderId, { include: 'orderlines' })); + } + }); + } + + /** Save order on the server */ + saveOrder = async (event, notif = true, update = false) => { + let order = this.props.order?.id; + // Create order if not fetched + if (!order) { + const action = this.getOrderAction().create({}); + order = (await action.payload).data.id; + this.props.dispatch(action); + } + + // Save all orderlines + const options = { withCredentials: true }; + const promises = Promise.all( + Object.entries(this.state.quantities).reduce((calls, [item, quantity]) => { + const data = { order, item, quantity }; + calls.push(apiAxios.post('orderlines', data, options)); + return calls; + }, []) + ); + + if (notif) + promises.then(resp => console.log('Saved')); + if (update) + promises.then(this.fetchOrder); + return promises + } + + /** Redirect to payment */ + payOrder = async event => { + const orderId = this.props.order?.id; + if (orderId == null) + return this.fetchOrder(); + + const returnUrl = window.location.href.replace(this.props.location.pathname, `/orders/${orderId}`); + try { + const resp = await apiAxios.get(`/orders/${orderId}/pay?return_url=${returnUrl}`, { withCredentials: true }); + window.location.href = resp.data['redirect_url']; + } catch (error) { + this.props.dispatch(messagesActions.pushError(error, "Erreur avec votre commande")); + this.fetchOrder(); + } + } + + /** Cancel an order */ + cancelOrder = event => { + apiActions.orders.delete(this.props.order.id).payload.finally(this.fetchOrder); + } + + /** Save order and redirect to payment */ + handleBuy = event => { + if (this.canBuy()) { + this.setState({ buying: true }, async () => { + await this.saveOrder(null, false, false); + await this.payOrder(); + }); + } + } + + // ----------------------------------------------------- + // Event handlers + // ----------------------------------------------------- + + toggleCGV = event => this.setState(prevState => ({ cgvAccepted: !prevState.cgvAccepted })) + + handleQuantityChange = event => { + const id = Number(event.currentTarget.dataset.itemId); + const value = Number(event.currentTarget.value); + this.setState(prevState => ({ + quantities: { + ...prevState.quantities, + [id]: value, + }, + })); + } + + handleReset = event => this.setState({ quantities: {} }) + + // ----------------------------------------------------- + // Display + // ----------------------------------------------------- + + hasUnpaidOrder = () => Boolean(this.props.order && this.props.order.status === 3) + + areItemsDisabled = () => Boolean(!this.props.authenticated || this.hasUnpaidOrder()) + + hasItemsInCart = () => Object.values(this.state.quantities).some(qt => qt > 0) + + canBuy = () => ( + this.props.authenticated + && this.state.cgvAccepted + && this.state.saleState?.key === 'ONGOING' + && this.hasItemsInCart() + ) + + render() { + const { classes, sale } = this.props; + const { cgvAccepted, saleState } = this.state; + if (!sale || this.props.fetchingSale) + return + + const CGVLink = props => + return ( + + + + {sale.name} + Par {sale.association?.shortname || "..."} + + + + + + + Description + {sale.description} + + Liens + + Conditions Vénérales de Ventes + + + Dates + + Ouverture: {sale.begin_at ? formatDate(sale.begin_at) : "Inconnue"} + Fermeture: {sale.end_at ? formatDate(sale.end_at) : "Inconnue"} + + + + + Articles en ventes + + {saleState?.key === 'NOT_BEGUN' && ( + + La vente n'a pas encore commencée + + Encore un peu de patience ! + Revenez d'ici {sale.begin_at && formatDate(sale.begin_at, 'fromNowStrict')} pour pouvoir commander. + + {this.state.countdown && ( + + + + )} + + )} + {saleState?.key === 'FINISHED' && ( + + La vente est terminée + Vous pouvez retrouver vos commandes liées à cette vente, sur votre compte. - - )} - - - - - - - {saleState === 'ONGOING' && ( - - - )} - label={( - + + )} + {saleState?.key === 'ONGOING' && ( + + + )} + label={( + J'accepte les conditions générales de ventes - )} - /> - - - - - Pour acheter - - {!this.props.authenticated && ( - - Veuillez vous connecter pour - acheter. - - )} - {!this.state.cgvAccepted && ( - - Veuillez accepter les CGV ci-dessus. - - )} - - - - - - )} - - {saleState === 'NOT_BEGUN' ? ( - - - - La vente n'a pas encore commencée, encore un peu de patience - ! - - - - - ) : ( - - - } - className={classes.buttonEmpty} - variant="outlined" - > - Vider - - {/* SAVE BUTTON, utile ?? + )} + /> + + + + + Pour acheter + + {!this.props.authenticated && ( + + Veuillez vous connecter pour acheter. + + )} + {!this.state.cgvAccepted && ( + + Veuillez accepter les CGV ci-dessus. + + )} + + + + + + )} + + + + + + + + } - className={classes.button} + onClick={this.handleReset} + disabled={!this.hasItemsInCart()} + startIcon={} + className={classes.buttonEmpty} variant="outlined" > - Sauvegarder + Vider + + {/* SAVE BUTTON, utile ?? + } + className={classes.button} + variant="outlined" + > + Sauvegarder + + */} + } + className={classes.buttonBuy} + variant="contained" + > + Acheter - */} - } - className={classes.buttonBuy} - variant="contained" - > - Acheter - - - - - )} - - - - - - - - - ) - } + + + + + + + + + ) + } } SaleDetail.propTypes = { diff --git a/src/pages/public/Sales.jsx b/src/pages/public/Sales.jsx index db0fa88..827d532 100644 --- a/src/pages/public/Sales.jsx +++ b/src/pages/public/Sales.jsx @@ -1,25 +1,58 @@ -import React from 'react' -import { useStoreAPIData } from '../../redux/hooks'; +import React from "react"; +import { Container, Box } from "@material-ui/core"; -import { Container, Grid } from '@material-ui/core'; -import SaleCard, { SaleCardSkeleton } from '../../components/sales/SaleCard'; +import APIDataTable from "components/common/APIDataTable"; +import { Link } from "components/common/Nav"; +import { PORTAIL_URL } from "utils/constants"; +import { formatDate } from "utils/format"; +import { useStoreAPIData } from "redux/hooks"; export default function Sales(props) { const sales = useStoreAPIData('sales', { queryParams: { include: 'association', order_by: 'begin_at' }}); - return ( - - Liste des ventes - - - {sales ? ( - Object.values(sales).map(sale => ) - ) : ( - [...Array(3).keys()].map(index => ) - )} - - + + + Liste des ventes} + path={["sales"]} + queryParams={{ + include: "association", + order_by: "-begin_at", + }} + columns={[ + { + title: "Lien", + field: "id", + render: (sale) => ( + {sale.id} + ), + }, + { + title: "Titre", + field: "name", + }, + { + title: "Association", + render: (sale) => ( + + {sale.association?.shortname} + + ) + }, + { + title: "Début", + field: "begin_at", + render: (sale) => formatDate(sale.begin_at, "datetime"), + } + ]} + /> + + ); } diff --git a/src/pages/public/UserOrders.jsx b/src/pages/public/UserOrders.jsx new file mode 100644 index 0000000..eb95897 --- /dev/null +++ b/src/pages/public/UserOrders.jsx @@ -0,0 +1,24 @@ +import React from "react" +import { useSelector } from "react-redux"; +import OrdersTable from "../../components/orders/OrdersTable"; +import Loader from "../../components/common/Loader"; +import { Container, Box } from "@material-ui/core"; + + +export default function UserOrders(props) { + const userId = useSelector(store => store.api.getAuthUser("id", null)); + return ( + + + + Mes commandes} + path={["auth", "orders"]} + show={["sale", "items", "actions"]} + apiOptions={{ uri: `/users/${userId}/orders` }} + /> + + + + ); +} diff --git a/src/pages/public/index.jsx b/src/pages/public/index.jsx index d48d87d..d4e3bdd 100644 --- a/src/pages/public/index.jsx +++ b/src/pages/public/index.jsx @@ -1,12 +1,36 @@ -import React from 'react'; +import React from "react"; +import { useStoreAPIData } from "../../redux/hooks"; -// import SalesList from '../components/common/SalesList'; -import Sales from './Sales'; +import { Container, Grid, Box, Button } from "@material-ui/core"; +import SaleCard, { SaleCardSkeleton } from "../../components/sales/SaleCard"; -// TODO: Custom home -export default function PublicSite(props) { - return ( - - ); -}; +export default function Home(props) { + const { pagination, ...sales } = useStoreAPIData("sales", { + include: "association", + order_by: "-begin_at", + }); + return ( + + Liste des ventes + + + {sales.fetched + ? Object.values(sales.data).map((sale) => ( + + )) + : [...Array(3).keys()].map((index) => )} + + {pagination && ( + + = pagination.nbPages} + onClick={() => sales.fetchData({ page: pagination.lastFetched + 1 })} + > + Voir plus + + + )} + + ); +} diff --git a/src/redux/actions.js b/src/redux/actions.js deleted file mode 100644 index 811b508..0000000 --- a/src/redux/actions.js +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Création et gestion automatique des actions que l'on dispatch via redux - * - * @author Samy Nastuzzi - * @author Alexandre Brasseur - * - * @copyright Copyright (c) 2018, SiMDE-UTC - * @license GNU GPL-3.0 - */ -import axios from 'axios'; -import { API_URL } from '../constants'; -import { API_PREFIX, MESSAGE_PREFIX } from './store'; - -// Default axios for the api -export const apiAxios = axios.create({ - baseURL: API_URL, - xsrfHeaderName: 'X-CSRFToken', - xsrfCookieName: 'csrftoken', -}); - -/* -|--------------------------------------------------------- -| Message System Actions -|--------------------------------------------------------- -*/ - -export const messagesActions = { - pushError: (error, title=null, params={}) => { - console.error(title || "Erreur inconnue", error); - if (error.isAxiosError && error.response) { - const { data, status } = error.response; - title = title || data.error || `Erreur API inconnue (${status})`; - const details = data.message; - return messagesActions.pushMessage(title, details, "error", params); - } - return messagesActions.pushMessage(title || "Erreur inconnue", String(error), "error", params) - }, - - pushMessage: (title, details=null, severity=null, params={}) => ({ - type: `${MESSAGE_PREFIX}_PUSH`, - payload: { - id: Math.random().toString(36).substring(2), - title, details, severity, params, - }, - }), - - popMessage: (id) => ({ - type: `${MESSAGE_PREFIX}_POP`, - payload: { id }, - }), -}; - -/* -|--------------------------------------------------------- -| Action Methods -|--------------------------------------------------------- -*/ - -// Methods calling the API with alliases -export const API_METHODS = { - all: { - type: 'ALL', - method: 'get', - action: 'updateAll', - }, - find: { - type: 'FIND', - method: 'get', - action: 'update', - }, - create: { - type: 'CREATE', - method: 'post', - action: 'insert', - }, - update: { - type: 'UPDATE', - method: 'put', - action: 'update', - }, - remove: { - type: 'DELETE', - method: 'delete', - action: 'delete', - }, - delete: { - type: 'DELETE', - method: 'delete', - action: 'delete', - }, -}; -API_METHODS.get = API_METHODS.find; -API_METHODS.remove = API_METHODS.delete; - -// Methods modifying the action -export const ACTION_CONFIG_METHODS = { - - /** Define the path for the resource in the store */ - definePath: action => path => { - action.path = path.slice(); - action.pathLocked = true; - return new Proxy(action, actionHandler); - }, - - /** Define the path for the resource in the store */ - defineUri: action => uri => { - action.uri = uri; - return new Proxy(action, actionHandler); - }, - - setUriFromPath: action => path => { - action.path = path.slice(); - action.uri = path.join('/'); - action.idIsGiven = path.length % 2 === 0; - return new Proxy(action, actionHandler); - }, - - /** Add a valid status */ - addValidStatus: action => validStatus => { - action.validStatus.push(validStatus); - return new Proxy(action, actionHandler); - }, - - /** Define the valid status */ - defineValidStatus: action => validStatus => { - action.validStatus = validStatus; - return new Proxy(action, actionHandler); - }, - - /** Set Action options */ - setOptions: action => options => { - action.options = { ...action.options, ...options }; - return new Proxy(action, actionHandler); - }, - - - // TODO Custom methods - auth: action => (authId = null) => { - action.path = ['auth']; - action.uri = authId ? `/users/${authId}` : 'auth/me'; - return new Proxy(action, actionHandler); - }, -}; - - -/* -|--------------------------------------------------------- -| Handler and APIAction class -|--------------------------------------------------------- -*/ - -// Gestionnaire d'actions (crée dynamiquement les routes api à appeler et où stocker les données) -export const actionHandler = { - get(action, attr) { - // Access instance - if (attr === '_instance') - return action; - - // Real attribute of Action - if (action[attr] !== undefined) - return action[attr]; - - // Methods that configure the action - if (attr in ACTION_CONFIG_METHODS) - return ACTION_CONFIG_METHODS[attr](action); - - // Build the API query method - const apiMethod = (...args) => { - let id, queryParams, jsonData; - - // GET query on a single element - if (['find', 'get'].includes(attr)) { - if (args.length > 0 || action.idIsGiven) { - if (action.idIsGiven) { - [queryParams, jsonData] = args; - } else { - [id, queryParams, jsonData] = args; - action.addId(id); - } - return action.generateAction('get', queryParams, jsonData); - } - // ID not specified, fallback to all - return action.generateAction('all'); - } - - // GET on multiple elements - if (attr === 'all') { - [queryParams, jsonData] = args; - return action.generateAction('all', queryParams, jsonData); - } - - // POST PUT DELETE an element - if (['create', 'update', 'remove', 'delete'].includes(attr)) { - if (action.idIsGiven || attr === 'create') { - [queryParams, jsonData] = args; - } else { - [id, queryParams, jsonData] = args; - action.addId(id); - } - return action.generateAction(attr, queryParams, jsonData); - } - - // Not an HTTP Method (ex: actions.users(1)) - if (args.length === 1) - action.addId(args[0]); - return new Proxy(action, actionHandler); - }; - - // HTTP Action (ex: actions.users.get()) - if (attr in API_METHODS) - return apiMethod; - - // If not, callback the apiMethod and build the URI - // Example: `actions.users` build the URI /users - action.addUri(attr); - - return new Proxy(apiMethod, { get: (func, key) => func()[key] }); - }, -}; - -// REST Action management class -export class APIAction { - constructor(axios_instance = apiAxios) { - this.axios = axios_instance; - this.uri = ''; - this.idIsGiven = false; - this.path = []; - this.pathLocked = false; - this.actions = API_METHODS; - this.validStatus = [200, 201, 202, 203, 204, 416]; - this.options = { - type: undefined, - axios: {}, - meta: {}, - action: {}, - }; - - return new Proxy(this, actionHandler); - } - - addUri(step) { - this.uri += `/${step}`; - - if (!this.pathLocked) { - this.path.push(step); - this.idIsGiven = false; - } - } - - addId(id) { - this.uri += `/${id}`; - - if (!this.pathLocked) { - this.path.push(id); - this.idIsGiven = true; - } - } - - generateQueries(queryParams, prefix) { - const queries = []; - - for (const key in queryParams) { - if (queryParams.hasOwnProperty(key)) { - const value = queryParams[key]; - - if (value !== undefined) { - if (Object.is(value)) - queries.push(this.generateQueries(value, true)); - else - queries.push( - `${encodeURIComponent(prefix ? `[${key}]` : key)}=${encodeURIComponent(value)}` - ); - } - } - } - return queries.join('&'); - } - - generateUri(uri, queryParams) { - const queries = this.generateQueries(queryParams); - return uri + (queries.length === 0 ? '' : `?${queries}`); - } - - generateType(action) { - return [ API_PREFIX, this.actions[action].type, ...this.path ].join('_').toUpperCase(); - } - - generateAction(action, queryParams = {}, jsonData = {}) { - const actionData = this.actions[action]; - return { - type: this.options.type || this.generateType(action), - meta: { - action: actionData.action, - validStatus: this.validStatus, - path: this.path, - timestamp: Date.now(), - ...this.options.meta, - }, - payload: this.axios.request({ - url: this.generateUri(this.uri, queryParams), - method: actionData.method, - data: jsonData, - withCredentials: true, - ...this.options.axios, - }), - ...this.options.action, - }; - } -} - -/** - * Actions are created dynamically (each use returns a new APIAction instance) - * Examples: - * - actions.users.all() - * - actions.users(1).orders.create(null, { status: 'ok' }) - */ -export const actions = new Proxy(axios_instance => new APIAction(axios_instance), { - get(target, attr) { - return new APIAction()[attr]; - }, -}); - -export default actions; diff --git a/src/redux/actions/api.js b/src/redux/actions/api.js new file mode 100644 index 0000000..ab587ba --- /dev/null +++ b/src/redux/actions/api.js @@ -0,0 +1,247 @@ +/** + * Création et gestion automatique des actions que l'on dispatch via redux + * + * @author Samy Nastuzzi + * @author Alexandre Brasseur + * + * @copyright Copyright (c) 2018, SiMDE-UTC + * @license GNU GPL-3.0 + */ +import { apiAxios } from 'utils/api'; +import { isObject } from 'utils/helpers'; +import { API_REDUX_PREFIX, DATA_CHANGES, DATA_SCOPES } from 'redux/constants'; + + +/** + * Methods calling the API with alliases + */ +export const API_METHODS = { + all: { + type: 'ALL', + httpMethod: 'get', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.MULTIPLE, + takesId: false, + }, + find: { + type: 'FIND', + httpMethod: 'get', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.ONE, + takesId: true, + }, + create: { + type: 'CREATE', + httpMethod: 'post', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.ONE, + takesId: false, + }, + update: { + type: 'UPDATE', + httpMethod: 'put', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.ONE, + takesId: true, + }, + delete: { + type: 'DELETE', + httpMethod: 'delete', + dataChange: DATA_CHANGES.REMOVE, + dataScope: DATA_SCOPES.ONE, + takesId: true, + }, + get: { + type: 'GET', + httpMethod: 'get', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.FULL, + takesId: false, + }, +}; + +/** + * Shortcuts and helpers to the API Action + */ +export const apiShortcuts = { + /** + * Get information about the specified or authenticated user + */ + authUser: action => { + function addAuthPath(userId = null, skipUri = false) { + if (action.path.length) + console.warning(`actions.api.authUser should be called first, path is ${action.path}`) + + action.path = ['auth']; + if (!skipUri) + action.uri = userId != null ? `/users/${userId}` : 'auth/me'; + return new Proxy(action, apiActionHandler); + } + return new Proxy(addAuthPath, { get: (func, key) => func()[key] }); + }, +}; + +/** + * Action handler that dynamically creates the URI path to the resource + */ +export const apiActionHandler = { + get(action, attr) { + // Access instance + if (attr === '_instance') + return action; + + // Access a real attribute of this Action + if (action[attr] !== undefined) + return action[attr].bind(action); + + if (attr in apiShortcuts) + return apiShortcuts[attr](action); + + // HTTP Action (ex: `actions.api.users.all()`) + if (attr in API_METHODS) { + return function (...args) { + const methodData = API_METHODS[attr]; + + let id, queryParams, jsonData; + if (methodData.takesId) + [id, queryParams, jsonData] = args; + else + [queryParams, jsonData] = args; + + if (id != null && !action.idIsGiven) + action.addId(id); + + return action.generateAction(methodData, queryParams, jsonData); + } + } + + // At this point, we dynamically build the URI + // Example: `actions.api.users` build the URI /users + action.addUri(attr); + + // Next we return a Proxy on a function + // that will be used to specify a resource id + // Example: `actions.api.users(1)` + // If it is not called, we will access the Action with a Proxy anyway + function resourceSpecifier(id) { + if (id != null) + action.addId(id); + return new Proxy(action, apiActionHandler); + } + + return new Proxy(resourceSpecifier, { get: (func, key) => func()[key] }); + }, +}; + +/** + * API Action generator + */ +export class APIAction { + constructor(axiosInstance = apiAxios) { + this.axios = axiosInstance; + this.uri = ''; + this.idIsGiven = false; + this.path = []; + this.pathLocked = false; + this.options = { + type: undefined, + axios: {}, + meta: {}, + }; + + return new Proxy(this, apiActionHandler); + } + + configure(modify) { + if (modify) + modify(this); + return new Proxy(this, apiActionHandler); + } + + addUri(step) { + this.uri += `/${step}`; + this.idIsGiven = false; + + if (!this.pathLocked) + this.path.push(step); + } + + addId(id) { + this.uri += `/${id}`; + this.idIsGiven = true; + + if (!this.pathLocked) + this.path.push(id); + } + + /** + * Transform an object into a queryParams string recursively if needed + * @param {Object} queryParams The object to stringify + * @param {Boolean} prefix Used when processing recursively + * @return {String} The queryParams string + */ + stringifyQueryParams(queryParams, prefix=undefined) { + const queries = []; + + for (const key in queryParams) { + if (queryParams.hasOwnProperty(key)) { + const value = queryParams[key]; + + if (value !== undefined) { + const _key = encodeURIComponent(key); + const prefixedKey = prefix ? `${prefix}[${_key}]` : _key; + if (isObject(value)) + queries.push(this.stringifyQueryParams(value, prefixedKey)); + else + queries.push(`${prefixedKey}=${encodeURIComponent(value)}`); + } + } + } + return queries.join('&'); + } + + generateUri(uri, queryParams = {}) { + const queries = this.stringifyQueryParams(queryParams); + return uri + (queries.length > 0 ? `?${queries}`: ''); + } + + generateType(methodType) { + return [ API_REDUX_PREFIX, methodType, ...this.path ].join('_').toUpperCase(); + } + + generateAction(methodData, queryParams = {}, jsonData = {}) { + return { + type: this.options.type || this.generateType(methodData.type), + meta: { + path: this.path, + idIsGiven: this.idIsGiven, + dataChange: methodData.dataChange, + dataScope: methodData.dataScope, + timestamp: Date.now(), + ...this.options.meta, + }, + payload: this.axios.request({ + url: this.generateUri(this.uri, queryParams), + method: methodData.httpMethod, + data: jsonData, + withCredentials: true, + ...this.options.axios, + }), + }; + } +} + +/** + * Actions are created dynamically (each use returns a new APIAction instance) + * Examples: + * - actions.api.users.all() + * - actions.api.users(1).orders.create(null, { status: 'ok' }) + */ +const apiActions = new Proxy(axiosInstance => new APIAction(axiosInstance), { + get(target, attr) { + // If the axiosInstance is not specified through the call, we use the default one + return new APIAction()[attr]; + }, +}); + +export default apiActions; diff --git a/src/redux/actions/index.js b/src/redux/actions/index.js new file mode 100644 index 0000000..fe733a4 --- /dev/null +++ b/src/redux/actions/index.js @@ -0,0 +1,5 @@ +export { default as apiActions } from './api'; +export { default as userActions } from './user'; +export { default as messagesActions } from './messages'; + +export { APIAction, apiAxios } from './api'; diff --git a/src/redux/actions/messages.js b/src/redux/actions/messages.js new file mode 100644 index 0000000..c58ec68 --- /dev/null +++ b/src/redux/actions/messages.js @@ -0,0 +1,37 @@ +import { MESSAGE_REDUX_PREFIX } from "redux/constants"; + +const messagesActions = { + pushError: (error, title = null, params = {}) => { + console.error(title || "Erreur inconnue", error); + if (error.isAxiosError && error.response) { + const { data, status } = error.response; + title = title || data.error || `Erreur API inconnue (${status})`; + const details = data.message; + return messagesActions.pushMessage(title, details, "error", params); + } + return messagesActions.pushMessage( + title || "Erreur inconnue", + String(error), + "error", + params + ); + }, + + pushMessage: (title, details = null, severity = null, params = {}) => ({ + type: `${MESSAGE_REDUX_PREFIX}_PUSH`, + payload: { + id: Math.random().toString(36).substring(2), + title, + details, + severity, + params, + }, + }), + + popMessage: (id) => ({ + type: `${MESSAGE_REDUX_PREFIX}_POP`, + payload: { id }, + }), +}; + +export default messagesActions; diff --git a/src/redux/constants.js b/src/redux/constants.js new file mode 100644 index 0000000..b69b9e0 --- /dev/null +++ b/src/redux/constants.js @@ -0,0 +1,28 @@ +/** + * Type prefixes for each reducer + */ +export const API_REDUX_PREFIX = "API"; +export const MESSAGE_REDUX_PREFIX = "MESSAGE"; + +/** + * Redux promises suffixes + */ +export const ASYNC_SUFFIXES = { + loading: "LOADING", + success: "SUCCESS", + error: "ERROR", +}; + +/** + * Data changes and scopes enums + */ +export const DATA_CHANGES = { + ASSIGN: "ASSIGN", + REMOVE: "REMOVE", +}; + +export const DATA_SCOPES = { + ONE: "ONE", + MULTIPLE: "MULTIPLE", + FULL: "FULL", +}; diff --git a/src/redux/hooks.js b/src/redux/hooks.js index 354f76e..1e94e82 100644 --- a/src/redux/hooks.js +++ b/src/redux/hooks.js @@ -1,90 +1,35 @@ -import { useEffect } from 'react'; -import useDeepCompareEffect from 'use-deep-compare-effect'; -import { useSelector, useDispatch } from 'react-redux'; -import { pathToArray } from './store'; -import actions, { APIAction, apiAxios } from './actions'; - - -const USE_API_STORE_DEFAULT_OPTIONS = { - action: 'get', - queryParams: undefined, - jsonData: undefined, - - raiseError: true, - fetchingValue: undefined, -}; - -// FIXME Process pagination -export function useStoreAPIData(path, options = {}) { - // Patch params - path = pathToArray(path); - options = { ...USE_API_STORE_DEFAULT_OPTIONS, ...options }; - if (options.action === 'get' && path.length % 2) - options.action = 'all'; - - // Get data from redux store - const dispatch = useDispatch(); - const resource = useSelector(store => store.get(path)); - - // FIXME Fires fetching multiple times - useDeepCompareEffect(() => { - // Fetch if not fetched - if (!resource.fetched && !resource.fetching) { - const action = new APIAction(); - action.path = path; - action.uri = path.join('/'); - const actionData = action.generateAction(options.action, options.queryParams, options.jsonData); - // console.log(actionData) - dispatch(actionData); - } - - // Raise error if needed - if (resource.error && options.raiseError) - throw Error(resource.error); - }, [resource, path, options, dispatch]); - - if (!resource.fetched || resource.fetching) - return options.fetchingValue; - - return resource.data; -} - -export async function useUpdateOrderStatus(orderId, auto = { fetch: false, redirect: false }) { - const dispatch = useDispatch(); - const resp = (await apiAxios.get(`/orders/${orderId}/status`)).data - - const fetchOrder = () => dispatch(actions.orders.find(orderId)); - const redirectToPayment = () => resp.redirect_url ? window.location.href = resp.redirect_url : null; - - if (auto.fetch && resp.updated) - fetchOrder(); - if (auto.redirect && resp.redirect_url) - redirectToPayment(); - - return { resp, fetchOrder, redirectToPayment }; -} - -function fetchOrders(dispatch, userId) { - dispatch( - actions - .defineUri(`users/${userId}/orders`) - .definePath(['auth', 'orders']) - .all({ - order_by: '-id', - include: 'sale,orderlines,orderlines__item,orderlines__orderlineitems', - }) +import useDeepCompareEffect from "use-deep-compare-effect"; +import { useSelector, useDispatch } from "react-redux"; +import { pathToArray } from "./reducers/api"; +import { APIAction, API_METHODS } from "./actions/api"; + +/** + * Hook to get data from the store using automatic API calls + */ +export function useStoreAPIData(_path, queryParams = undefined, options = {}) { + const path = pathToArray(_path); + const actionData = options.actionData || ( + options.singleElement ? API_METHODS.find : API_METHODS.all ); -} -export function useUserOrders() { + // Get data from redux store const dispatch = useDispatch(); - const userId = useSelector(store => store.getAuthUser('id', null)); - const orders = useSelector(store => store.getAuthRelatedData('orders', undefined)); - - useEffect(() => { - if (userId) - fetchOrders(dispatch, userId); - }, [dispatch, userId]); - - return { userId, orders, fetchOrders: () => fetchOrders(dispatch, userId) }; + const resource = useSelector(store => store.api.get(path)); + + function fetchData(additionalQuery = null, returnAction = false) { + const actionGen = new APIAction(); + actionGen.path = path; + actionGen.uri = options.uri || path.join("/"); + actionGen.idIsGiven = Boolean(options.singleElement); + const query = additionalQuery ? { ...queryParams, ...additionalQuery } : queryParams; + const action = actionGen.generateAction(actionData, query) + dispatch(action); + if (returnAction) + return action; + } + + // At first use or when data changes, automaticaly fire fetching + useDeepCompareEffect(fetchData, [actionData, path, queryParams, options, dispatch]); + + return { ...resource, fetchData }; } diff --git a/src/redux/reducers/api.js b/src/redux/reducers/api.js new file mode 100644 index 0000000..d7b7e9b --- /dev/null +++ b/src/redux/reducers/api.js @@ -0,0 +1,336 @@ +import produce from 'immer'; +import { DEFAULT_PAGE_SIZE } from 'utils/constants'; +import { deepcopy, isEmpty } from 'utils/helpers'; +import { API_REDUX_PREFIX, ASYNC_SUFFIXES, DATA_CHANGES, DATA_SCOPES } from 'redux/constants'; + +/* +|--------------------------------------------------------- +| Path helpers +|--------------------------------------------------------- +*/ + +/** + * Split a URI route into an array + * Example: 'assos/1/calendars' => ['assos', '1', 'calendars'] + */ +export function pathToArray(path) { + if (typeof path === 'string') + return path.split('/'); + else if (path instanceof Array) + return path.slice(); + else + return []; +} + + +/* +|--------------------------------------------------------- +| Default Store +|--------------------------------------------------------- +| This API store is auto building itself with each request +*/ + +// Base storage for each resource +export const INITIAL_RESOURCE_STATE = { + data: {}, + error: null, + failed: false, + status: null, + fetching: false, + fetched: false, + lastUpdate: null, + pagination: {}, + resources: {}, +}; + +// La racine du store +export const DEFAULT_API_STORE = { + + // Actual store + resources: {}, + + /** + * Easy access an element in the store + * Should NOT return copied data from the store (arr.map, Object.values) for better performance + * + * @param {} path The path to the target resource + * @param {} [replacement={}] The returned Objet if the resource if infindable + * @param {boolean} [forceReplacement=false] Return remplacement resource is empty or null + */ + get(path, replacement = INITIAL_RESOURCE_STATE, forceReplacement = true) { + let data = this; + + // Find the resource from the path + // Search in direct data and in resources + for (const step of pathToArray(path)) { + if (data[step] !== undefined) + data = data[step]; + else if (data.resources && data.resources[step] !== undefined) + data = data.resources[step]; + else + return replacement; + } + + // Return replacement if the data is empty or null + if (forceReplacement && isEmpty(data)) + return replacement; + + return data; + }, + + /** Retrieve the data object of a resource */ + getData(path, replacement = null, forceReplacement = false) { + return this.get([...pathToArray(path), 'data'], replacement, forceReplacement); + }, + + /** Retrieve the data with a particuliar value of a resource */ + findData(path, value, key = 'id', replacement = null, forceReplacement = true) { + // Data is stored by id so simply access it + if (key === 'id') + return this.getData([...pathToArray(path), value], replacement, forceReplacement); + + // Otherwise, search the data for the right key + const data = this.getData(path); + for (const k in data) + if (data[k][key] === value) + return data[k]; + + return replacement; + }, + + // Custom methods to retrieve the authenticated user resources + getAuthUser(path, replacement = null, forceReplacement = true) { + return this.get(['auth', 'data', 'user', ...pathToArray(path)], replacement, forceReplacement); + }, + getAuthRelatedData(path, replacement = {}, forceReplacement = true) { + return this.getData(['auth', 'resources', ...pathToArray(path)], replacement, forceReplacement); + }, +}; + + +/* +|--------------------------------------------------------- +| Resource helpers +|--------------------------------------------------------- +*/ + +/** + * Helper to get path and id from an action meta + */ +function getPathFromMeta(meta) { + const path = meta.path.slice(); + let id = undefined; + + // Pop id from path if needed + if (meta.idIsGiven) + id = path.pop(); + + return { path, id }; +} + +/** + * Dynamically generate the resource storage in the store from the path + * @param {Object} store The Redux API store + * @param {String[]} path The path to the place wanted + * @return {Object} The place located at the required steps + */ +function buildPathInStore(store, path) { + return path.reduce((place, step) => { + // Create nested resources if doesn't exist + if (place.resources[step] === undefined) + place.resources[step] = deepcopy(INITIAL_RESOURCE_STATE); + + // Go forward in the path + return place.resources[step]; + }, store); +} + +/** + * Make a resource place successful + */ +function makeResourceSuccessful(resource, timestamp, status) { + resource.fetching = false + resource.fetched = true + resource.error = null + resource.failed = false + resource.lastUpdate = timestamp + resource.status = status +} + +/** + * Apply a change to one element in place according to dataChange + */ +function changeOneElement(place, dataChange, id, element, timestamp, status) { + if (id == null) + throw Error(`Invalid id ${id}`) + + switch (dataChange) { + case DATA_CHANGES.ASSIGN: + place.data[id] = element; + + if (place.resources[id] === undefined) + place.resources[id] = deepcopy(INITIAL_RESOURCE_STATE); + + // Duplicate element in resources as it is only a pointer + makeResourceSuccessful(place.resources[id], timestamp, status); + place.resources[id].data = element; + break; + + case DATA_CHANGES.REMOVE: + delete place.data[id]; + if (place.resources[id] !== undefined) + delete place.resources[id] + break; + + default: + throw Error(`Unknown dataChange ${dataChange}`) + } +} + +/** + * Parse the pagination next and previous url to extract queryParams + */ +function parsePaginationUrl(url) { + if (url == null) + return {}; + const params = new URL(url).searchParams; + return { + page: parseInt(params.get('page')) || 1, + pageSize: parseInt(params.get('page_size')) || undefined, + }; +} + +/** + * Process pagination in place and return results + */ +export function processPagination(data, place = undefined) { + // No pagination + if (!data.hasOwnProperty('results')) { + if (place) + place.pagination = null; + return { data, pagination: null }; + } + + const { results, ...pagination } = data; + const prevPagination = (place && place.pagination) || {}; + + const prevParams = parsePaginationUrl(pagination.previous); + const nextParams = parsePaginationUrl(pagination.next); + const pageSize = prevParams.pageSize || nextParams.pageSize || DEFAULT_PAGE_SIZE; + const currentPage = (prevParams.page + 1) || (nextParams.page - 1) || 1; + + // If pagination is different, clean data as some might be missing with a different page size + const resetPagination = place && prevPagination.pageSize && prevPagination.pageSize !== pageSize; + if (resetPagination) { + place.data = {}; + place.resources = {}; + place.pagination = {}; + } + + const newPagination = { + count: pagination.count, + pageSize, + fetchedPages: { + ...(!resetPagination ? prevPagination.fetchedPages || {} : {}), + [currentPage]: Object.values(results).map(result => result.id), + }, + lastFetched: currentPage, + nbPages: Math.ceil(pagination.count / pageSize), + }; + + if (place) + place.pagination = newPagination; + + return { data: results, pagination: newPagination }; +} + +/** + * Get the status of the response + */ +function getStatus(payload) { + return payload.status || (payload.response && payload.response.status); +} + +/* +|--------------------------------------------------------- +| Reducer +|--------------------------------------------------------- +*/ + +/** This reducer manages the async API operations */ +export default function apiReducer(state = DEFAULT_API_STORE, action) { + + if (action.type && action.type.startsWith(API_REDUX_PREFIX)) { + return produce(state, draft => { + + // place is the resource place where to store the data + const { path, id } = getPathFromMeta(action.meta); + let place = buildPathInStore(draft, path); + + const callStatus = action.type.split('_').pop(); + switch (callStatus) { + + case ASYNC_SUFFIXES.loading: + place.fetching = true; + place.status = null; + return draft; + + case ASYNC_SUFFIXES.error: + place.fetching = false; + place.error = action.payload; + place.failed = true; + place.status = getStatus(action.payload); + return draft; + + case ASYNC_SUFFIXES.success: + // Get all data need from action + const { data } = processPagination(action.payload.data, place); + const { dataScope, dataChange, timestamp } = action.meta; + + const status = getStatus(action.payload); + makeResourceSuccessful(place, timestamp, status); + + switch (dataScope) { + case DATA_SCOPES.ONE: + const dataId = id || data.id; + changeOneElement(place, dataChange, dataId, data, timestamp, status); + break; + + case DATA_SCOPES.FULL: + switch (dataChange) { + case DATA_CHANGES.ASSIGN: + place.data = data; + break; + + case DATA_CHANGES.REMOVE: + delete place.data; + delete place.resources; + delete place.pagination; + break; + + default: + break; + } + break; + + case DATA_SCOPES.MULTIPLE: + Object.values(data).forEach((element, index) => { + const elementId = element.id || index; + changeOneElement(place, dataChange, elementId, element, timestamp, status); + }); + break; + + default: + break; + } + + return draft; + + default: + return draft; + } + }); + } + + return state; +} diff --git a/src/redux/reducers/index.js b/src/redux/reducers/index.js new file mode 100644 index 0000000..07a2a0e --- /dev/null +++ b/src/redux/reducers/index.js @@ -0,0 +1,11 @@ +import { combineReducers } from 'redux'; + +import messagesReducer from './messages'; +import apiReducer from './api'; + +const rootReducer = combineReducers({ + api: apiReducer, + messages: messagesReducer, +}) + +export default rootReducer diff --git a/src/redux/reducers/messages.jsx b/src/redux/reducers/messages.jsx new file mode 100644 index 0000000..3ac1071 --- /dev/null +++ b/src/redux/reducers/messages.jsx @@ -0,0 +1,21 @@ +import { MESSAGE_REDUX_PREFIX } from "redux/constants"; + +const DEFAULT_MESSAGE_STORE = []; + +/** + * Simple push/pop messaging system reducer + */ +export default function messagesReducer(state = DEFAULT_MESSAGE_STORE, action) { + switch (action.type) { + case `${MESSAGE_REDUX_PREFIX}_PUSH`: + const data = action.payload; + return [...state, data]; + + case `${MESSAGE_REDUX_PREFIX}_POP`: + const removeId = action.payload.id; + return state.filter((data) => data.id !== removeId); + + default: + return state; + } +} diff --git a/src/redux/store.js b/src/redux/store.js index 3bc8855..51eeac2 100644 --- a/src/redux/store.js +++ b/src/redux/store.js @@ -1,399 +1,43 @@ -/** - * Création et gestion automatique et dynmaique du store géré par redux (store refait sur la base du travail d'Alexandre) - * - * @author Samy Nastuzzi - * @author Alexandre Brasseur - * - * @copyright Copyright (c) 2018, SiMDE-UTC - * @license GNU GPL-3.0 - */ - -import produce from 'immer'; import { createStore, applyMiddleware } from 'redux'; -import { deepcopy, isEmpty } from '../utils'; +import rootReducer from './reducers'; -// Import Middlewares -import thunk from 'redux-thunk'; +// Middlewares import { createPromise } from 'redux-promise-middleware'; +// TODO Preload store ? +const preloadedStore = {}; + + /* |--------------------------------------------------------- | Middlewares |--------------------------------------------------------- */ -// Suffixes des actions asynchrones -const ASYNC_SUFFIXES = { +export const ASYNC_SUFFIXES = { loading: 'LOADING', success: 'SUCCESS', error: 'ERROR', }; -// Configure Middlewares -const middlewares = applyMiddleware( - thunk, +const middlewares = [ createPromise({ promiseTypeSuffixes: Object.values(ASYNC_SUFFIXES) }), - // require('redux-logger').createLogger({ collapsed: true }), -); - - -/* -|--------------------------------------------------------- -| Path helpers -|--------------------------------------------------------- -*/ +]; -/** - * Split a URI route into an array - * Example: 'assos/1/calendars' => ['assos', '1', 'calendars'] - */ -export function pathToArray(path) { - if (typeof path === 'string') - return path.split('/'); - else if (path instanceof Array) - return path.slice(); - else - return []; -} +if (process.env.NODE_ENV === 'development') + middlewares.push(require('redux-logger').createLogger({ collapsed: true })); -function mergePath(path, ...additionalSteps) { - return pathToArray(path).concat(additionalSteps); -} -/** Helper to get path and id from an action meta */ -function getPathFromMeta(meta) { - const path = meta.path.slice(); - let id = undefined; - - // Pop id from path if needed - if (!['updateAll', 'create', 'insert'].includes(meta.action)) { - id = path.pop(); - } - return { path, id }; -} +const enhancers = applyMiddleware(...middlewares); /* |--------------------------------------------------------- | Store |--------------------------------------------------------- -| This API store is auto building itself with each request */ -export const API_PREFIX = 'API'; -export const MESSAGE_PREFIX = 'MESSAGE'; - -// Base storage for each resource -export const INITIAL_RESOURCE_STATE = { - data: {}, // A map of the needed data by id - error: null, - failed: false, - status: null, - fetching: false, - fetched: false, - lastUpdate: null, - pagination: {}, - resources: {}, -}; - -// La racine du store -export const apiStore = { - - // Actual store - resources: {}, - messages: {}, - - /** - * Easy access an element in the store - * Should NOT return copied data from the store (arr.map, Object.values) for better performance - * - * @param {} path The path to the target resource - * @param {} [replacement={}] The returned Objet if the resource if infindable - * @param {boolean} [forceReplacement=false] Return remplacement resource is empty or null - */ - get(path, replacement = INITIAL_RESOURCE_STATE, forceReplacement = true) { - let data = this; - - // Find the resource from the path - // Search in direct data and in resources - for (const step of pathToArray(path)) { - if (data[step] !== undefined) - data = data[step]; - else if (data.resources && data.resources[step] !== undefined) - data = data.resources[step]; - else - return replacement; - } - - // Return replacement if the data is empty or null - if (forceReplacement && isEmpty(data)) - return replacement; - - return data; - }, - - /** Retrieve the data object of a resource */ - getData(path, replacement = null, forceReplacement = false) { - return this.get(mergePath(path, 'data'), replacement, forceReplacement); - }, - - /** Retrieve the data with a particuliar value of a resource */ - findData(path, value, key = 'id', replacement = null, forceReplacement = true) { - // Data is stored by id - if (key === 'id') - return this.getData(mergePath(path, value), replacement, forceReplacement); - - // Otherwise, search the data for the right key - const data = this.getData(path); - for (const k in data) - if (data[k][key] === value) - return data[k]; - - return replacement; - }, - - /** - * Get specified resource for multiple data by id - * Example: ('sales', 'assos') => { a: asso_of_sale_a, b: asso_of_sale_b } - * - * @param {[type]} path [description] - * @param {[type]} resource [description] - * @param {[type]} replacement [description] - * @return {Object} La map de resource par id - */ - getResourceDataById(path, resource, replacement = null) { - const pathResources = this.get(path).resources; - return Object.keys(pathResources).reduce((acc, id) => { - const subResources = pathResources[id].resources[resource] || {}; - acc[id] = subResources.fetched ? subResources.data : replacement; - return acc; - }, {}); - }, - - // TODO Custom methods - getAuthUser(path, replacement = null, forceReplacement = true) { - return this.get(['auth', 'data', 'user', ...pathToArray(path)], replacement, forceReplacement); - }, - getAuthRelatedData(path, replacement = {}, forceReplacement = true) { - return this.getData(['auth', 'resources', ...pathToArray(path)], replacement, forceReplacement); - }, -}; - - -/* -|--------------------------------------------------------- -| Resource helpers -|--------------------------------------------------------- -*/ - -/** - * Dynamically generate the resource storage in the store from the path - * @param {Object} store The Redux API store - * @param {String[]} path The path to the place wanted - * @return {Object} The place located at the required steps - */ -function buildPathInStore(store, path) { - return path.reduce((place, step) => { - // Create nested resources if doesn't exist - if (place.resources[step] === undefined) - place.resources[step] = deepcopy(INITIAL_RESOURCE_STATE); - - // Go forward in the path - return place.resources[step]; - }, store); -} - -function makeResourceSuccessful(resource, timestamp, status) { - resource.fetching = false - resource.fetched = true - resource.error = null - resource.failed = false - resource.lastUpdate = timestamp - resource.status = status - return resource -} - -function processPagination(payload) { - if (payload.hasOwnProperty('results')) { - const { results, ...pagination } = payload; - return { data: results, pagination: pagination }; - } else { - return { data: payload, pagination: null }; - } -} - - -/* -|--------------------------------------------------------- -| Reducer -|--------------------------------------------------------- -*/ - -/** This reducer manages the async API operations */ -export const apiReducer = (state = apiStore, action) => { - - // Message system actions - if (action.type && action.type.startsWith(MESSAGE_PREFIX)) { - return produce(state, draft => { - const data = action.payload; - switch (action.type) { - case `${MESSAGE_PREFIX}_PUSH`: - draft.messages[data.id] = data; - break; - case `${MESSAGE_PREFIX}_POP`: - delete draft.messages[data.id]; - break; - default: - break; - } - return draft; - }) - } - - // Api actions - if (action.type && action.type.startsWith(API_PREFIX)) { - return produce(state, draft => { - // Get path and id from action.meta - let { path, id } = getPathFromMeta(action.meta); - let place = buildPathInStore(draft, path); - - // CASE LOADING: Async call is loading - if (action.type.endsWith(`_${ASYNC_SUFFIXES.loading}`)) { - place.fetching = true; - place.status = null; - return draft; - } - - const status = (action.payload.status || ( - action.payload.response && action.payload.response.status - )); - const statusIsValid = action.meta.validStatus.includes(status); - - // ====== CASE ERROR: Async call has failed - if (action.type.endsWith(`_${ASYNC_SUFFIXES.error}`)) { - // if (id) // TODO ???? - // place = buildPathInStore(draft, path.concat([id])); - - // place.data = {}; - place.fetching = false; - place.fetched = statusIsValid; - place.error = action.payload; - place.failed = statusIsValid; - place.status = status; - return draft; - } - - // Async call has succeeded - if (action.type.endsWith(`_${ASYNC_SUFFIXES.success}`)) { - - // ====== CASE HTTP ERROR: HTTP status is not acceptable - if (!statusIsValid) { - // place.data = {}; - place.fetching = false; - place.fetched = false; - place.error = 'NOT ACCEPTED'; - place.failed = true; - place.status = action.payload.status; - return draft; - } - - // ====== CASE SUCCESS: Update store - - // Set pagination, timestamp, status and others indicators - const { timestamp, status } = action.payload; - const { data, pagination } = processPagination(action.payload.data); - if (pagination) - place.pagination = pagination - - // The resource place where to store the data - place = makeResourceSuccessful(place, timestamp, status); - id = id || data.id; // TODO - - // Helper to build a store for the data if it has a key or an id - function buildSuccessfulDataStorePath(element, key) { - if (key) { - let placeForData = buildPathInStore(draft, path.concat([key])); - placeForData = makeResourceSuccessful(placeForData, timestamp, status); - placeForData.data = element; - } - } - - // Update the data and resources according to the action required - if (action.meta.action === 'updateAll') { - // Multiple elements - - // Modify data and Create places in resources for each element according to id - if (Array.isArray(data)) { // Array: Multiple elements with id - data.forEach(element => { - const e_id = element.id; // TODO - place.data[e_id] = element; - buildSuccessfulDataStorePath(element, e_id); - }); - } else if (id) { // Resource with id: Single id - place.data = data; - buildSuccessfulDataStorePath(data, id); - } else { // Resource without id: keys for resources - // TODO Check object, Useful ?? - place.data = data; - // for (const key in data) - // buildSuccessfulDataStorePath(data[key], key); - } - - } else { // Single element - - // Modify place.data and place.resources - if (['create', 'insert', 'update'].includes(action.meta.action)) { - if (id) { - place.data[id] = data; - buildSuccessfulDataStorePath(data, id); - } else { - place.data = data; - for (const key in data) - buildSuccessfulDataStorePath(data[key], key); - } - } else if ('delete' === action.meta.action) { - if (id) { - delete place.data[id]; - delete place.resources[id]; - } else { - place.data = {}; - place.resources = {}; - } - } - } - - return draft; - } - - // Return the draft state anyway - return draft; - }); - } - - return state; -} - -/* -// TODO -const NAMESPACES_CONFIG = { - config: { - type: 'CONFIG', - reducer: (state = {}, action) => ({ - ...state, - config: { - ...state.config, - ...action.payload, - }, - }), - }, -} - -combineReducers({ - resources: apiReducer, - ...NAMESPACES_CONFIG, -}) -*/ +const store = createStore(rootReducer, preloadedStore, enhancers); -// Finally create and export the redux store -export default createStore(apiReducer, middlewares); +export default store; diff --git a/src/styles.js b/src/styles.js deleted file mode 100644 index 276221c..0000000 --- a/src/styles.js +++ /dev/null @@ -1,82 +0,0 @@ -import { makeStyles } from '@material-ui/core/styles'; -import { fade } from "@material-ui/core/styles/colorManipulator"; - - -// Utils - -export function mergeClasses(classes, ...names) { - return names.reduce((merged, name) => ( - name in classes ? `${merged} ${classes[name]}` : merged - ), '').slice(1); -} - -export function getButtonColoredVariant(theme, color, variant) { - const colors = theme.palette[color]; - switch (variant) { - case 'outlined': - return { - color: colors.main, - border: `1px solid ${fade(colors.main, 0.5)}`, - "&:hover": { - border: `1px solid ${colors.main}`, - backgroundColor: fade( - colors.main, - theme.palette.action.hoverOpacity - ), - "@media (hover: none)": { - backgroundColor: "transparent" - } - } - }; - case 'contained': - return { - color: colors.contrastText, - backgroundColor: colors.main, - "&:hover": { - backgroundColor: colors.dark, - "@media (hover: none)": { - backgroundColor: colors.main - } - } - }; - default: - case 'text': - return { - color: colors.main, - "&:hover": { - backgroundColor: fade(colors.main, theme.palette.action.hoverOpacity), - "@media (hover: none)": { - backgroundColor: "transparent" - } - } - }; - } -} - - -// Style decorators - -export const useFormStyles = makeStyles(theme => ({ - column: { - display: 'flex', - flexDirection: 'column', - paddingTop: '0 !important', - paddingBottom: '0 !important', - minWidth: 220, - }, - controls: { - maxWidth: 280, - }, - editor: { - // borderWidth: 1, - // borderStyle: 'solid', - // borderColor: 'transparent', - padding: theme.spacing(2), - }, - editing: { - borderColor: 'yellow', - }, - error: { - borderColor: 'red', - }, -})); diff --git a/src/themes.js b/src/themes.js index 03b542b..a54f468 100644 --- a/src/themes.js +++ b/src/themes.js @@ -1,57 +1,69 @@ -import { createMuiTheme, responsiveFontSizes } from '@material-ui/core/styles'; +import { createMuiTheme, responsiveFontSizes } from "@material-ui/core/styles"; function createResponsiveTheme(...args) { - return responsiveFontSizes(createMuiTheme(...args)); + return responsiveFontSizes(createMuiTheme(...args)); } export const WoollyTheme = createResponsiveTheme({ - themeName: 'Woolly', - typography: { - fontFamily: [ - '-apple-system', - 'BlinkMacSystemFont', - '"Segoe UI"', - 'Roboto', - '"Helvetica Neue"', - 'Arial', - 'sans-serif', - ].join(','), - }, - palette: { - primary: { - main: '#025862', - }, - secondary: { - main: '#2196f3', - light: '#00b7ff', - dark: '#1976d2', - contrastText: '#fff', - }, - success: { - main: "#008805", - }, - warning: { - main: '#FA8C05', - light: '#F2B705', - dark: '#EE6B4D', - contrastText: '#fff', - }, - error: { - main: "#e00000", - }, - neutral: { - dark: '#293241', - main: '#3D5B81', - light: '#98C0D9', - }, - }, - overrides: { - MuiFormControl: { - root: { - marginBottom: 10, - minWidth: 100, - maxWidth: 300, - }, - }, - }, + themeName: "Woolly", + typography: { + fontFamily: [ + "-apple-system", + "BlinkMacSystemFont", + '"Segoe UI"', + "Roboto", + '"Helvetica Neue"', + "Arial", + "sans-serif", + ].join(","), + }, + palette: { + primary: { + main: "#025862", + }, + secondary: { + main: "#2196f3", + light: "#00b7ff", + dark: "#1976d2", + contrastText: "#fff", + }, + success: { + main: "#008805", + }, + warning: { + main: "#FA8C05", + light: "#F2B705", + dark: "#EE6B4D", + contrastText: "#fff", + }, + error: { + main: "#e00000", + }, + neutral: { + dark: "#293241", + main: "#3D5B81", + light: "#98C0D9", + }, + }, + overrides: { + MuiFormControl: { + root: { + marginBottom: 10, + minWidth: 100, + maxWidth: 300, + }, + }, + MuiListItem: { + dense: { + paddingTop: 0, + paddingBottom: 0, + }, + }, + MuiListItemText: { + dense: { + marginTop: 0, + marginBottom: 0, + }, + }, + }, }); diff --git a/src/utils/api.js b/src/utils/api.js new file mode 100644 index 0000000..6d7f7a0 --- /dev/null +++ b/src/utils/api.js @@ -0,0 +1,84 @@ +import axios from "axios"; +import apiActions from "redux/actions/api"; +import { API_URL, SALE_STATUS } from "utils/constants"; +import { isEmpty } from "utils/helpers"; +import { isPast } from "date-fns"; + +/** + * Default axios for the API + */ +export const apiAxios = axios.create({ + baseURL: API_URL, + xsrfHeaderName: "X-CSRFToken", + xsrfCookieName: "csrftoken", +}); + +/** + * Check that a user a manager rights + */ +export function hasManagerRights(auth, userAssos) { + return auth.authenticated && (auth.user.is_admin || !isEmpty(userAssos)); +} + +/** + * Get the state of a sale + */ +export function getSaleState(sale) { + if (!sale) + return null; + if (sale.end_at && isPast(new Date(sale.end_at))) + return SALE_STATUS.FINISHED; + if (sale.begin_at && isPast(new Date(sale.begin_at))) + return SALE_STATUS.ONGOING; + return SALE_STATUS.NOT_BEGUN; +} + +/** + * Helper to update the status of an order + */ +export async function updateOrderStatus(dispatch, orderId, auto = { fetch: false, redirect: false }) { + const data = (await apiAxios.get(`/orders/${orderId}/status`)).data + + const fetchOrder = () => dispatch(apiActions.orders.find(orderId)); + const redirectToPayment = () => data.redirect_url ? window.location.href = data.redirect_url : null; + + if (auto.fetch && data.updated) + fetchOrder(); + if (auto.redirect && data.redirect_url) + redirectToPayment(); + + return { data, fetchOrder, redirectToPayment }; +} + +/** + * Helper to get Order status actions + */ +export function getStatusActions(dispatch, history) { + return { + updateStatus(event, id = undefined) { + const orderId = id || event.currentTarget.getAttribute("data-order-id"); + updateOrderStatus(dispatch, orderId, { fetch: true }); + }, + + download(event, id = undefined) { + const orderId = id || event.currentTarget.getAttribute("data-order-id"); + window.open(`${API_URL}/orders/${orderId}/pdf?download`, "_blank"); + }, + + modify(event, id = undefined) { + const orderId = id || event.currentTarget.getAttribute("data-order-id"); + history.push(`/orders/${orderId}`); + }, + + pay(event, id = undefined) { + const saleId = id || event.currentTarget.getAttribute("data-sale-id"); + history.push(`/sales/${saleId}`); + }, + + cancel(event, id = undefined) { + const orderId = id || event.currentTarget.getAttribute("data-order-id"); + const action = apiActions.orders(orderId).delete(); + action.payload.finally(() => updateOrderStatus(dispatch, orderId, { fetch: true })); + }, + }; +}; diff --git a/src/constants.js b/src/utils/constants.js similarity index 73% rename from src/constants.js rename to src/utils/constants.js index 3a47fd6..7acc20d 100644 --- a/src/constants.js +++ b/src/utils/constants.js @@ -1,16 +1,24 @@ -import { - Add, Check, Clear, Delete, Edit, GetApp, FilterList, ArrowUpward, - FirstPage, LastPage, NavigateNext, NavigateBefore, Search, Payment -} from '@material-ui/icons'; +import { Clear, Edit, GetApp, Payment } from '@material-ui/icons'; // Environment variables export const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000'; +export const PORTAIL_URL = 'https://assos.utc.fr'; // Regex -export const REGEX_SLUG = /^[a-zA-Z]([-_]?[a-zA-Z0-9])*$/; +export const SLUG_REGEX = /^[a-zA-Z]([-_]?[a-zA-Z0-9])*$/; +export const PAGE_SIZES = [10, 25, 50, 100]; +export const DEFAULT_PAGE_SIZE = 10; + +// Sales + +export const SALE_STATUS = { + NOT_BEGUN: {key: 'NOT_BEGUN', label: 'Ouverte prochaine... ', color: 'orange'}, + ONGOING: {key: 'ONGOING', label: 'En cours !', color: 'green'}, + FINISHED: {key: 'FINISHED', label: 'Terminée !', color: 'red'}, +} // Orders @@ -24,14 +32,14 @@ export const ORDER_STATUS = { 4: { color: '#008805', actions: ['download', 'modify'], label: 'Payée' }, 5: { color: '#e00000', actions: [], label: 'Expirée' }, 6: { color: '#e00000', actions: [], label: 'Annulée' }, -} +}; export const ORDER_ACTIONS = { - download: { text: "Télécharger les billets", Icon: GetApp, }, - modify: { text: "Modifier la commande", Icon: Edit, }, + download: { text: "Télécharger les billets", Icon: GetApp, }, + modify: { text: "Modifier la commande", Icon: Edit, }, pay: { text: "Payer la commande", Icon: Payment, }, - cancel: { text: "Annuler la commande", Icon: Clear, }, -} + cancel: { text: "Annuler la commande", Icon: Clear, }, +}; export const STATUS_MESSAGES = { 0: { severity: 'info', message: "Vous pouvez la complèter en cliquant sur le lien suivant.", link: "Finaliser ma commande" }, @@ -41,7 +49,7 @@ export const STATUS_MESSAGES = { 4: { severity: 'success', message: "Vous pouvez télécharger vos billets en utilisant le bouton en base de page ou bien modifier ceux qui sont éditables en cliquant sur les différents champs." }, 5: { severity: 'error', message: "Vous pouvez effectuer une autre commande sur la même vente en cliquant sur le lien suivant.", link: "Effectuer une autre commande" }, 6: { severity: 'error', message: "Vous pouvez effectuer une autre commande sur la même vente en cliquant sur le lien suivant.", link: "Effectuer une autre commande" }, -} +}; // Blank data @@ -89,25 +97,3 @@ export const BLANK_ITEMFIELD = { field: null, editable: true, }; - -// Icons - -export const MaterialTableIcons = { - Add: Add, - Check: Check, - Clear: Clear, - Delete: Delete, - // DetailPanel: DetailPanel, - Edit: Edit, - Export: GetApp, - Filter: FilterList, - FirstPage: FirstPage, - LastPage: LastPage, - NextPage: NavigateNext, - PreviousPage: NavigateBefore, - ResetSearch: Clear, - Search: Search, - SortArrow: ArrowUpward, - // ThirdStateCheck: ThirdStateCheck, - // ViewColumn: ViewColumn, -}; diff --git a/src/utils/format.js b/src/utils/format.js new file mode 100644 index 0000000..0838070 --- /dev/null +++ b/src/utils/format.js @@ -0,0 +1,59 @@ +import React from "react"; +import { + parseISO, + lightFormat, + formatDistanceToNow, + formatDistanceToNowStrict, +} from "date-fns"; +import { fr } from "date-fns/locale"; + +window.__localeId__ = 'fr'; + + +export function textOrIcon(text, Icon, displayText) { + return displayText ? text : +} + +export function shorten(text, limit) { + if (text?.length > limit) + return text.slice(0, limit - 3) + "..."; + return text; +} + +export function capitalFirst(text) { + return text.charAt(0).toLocaleUpperCase() + text.slice(1); +} + +export const priceFormatter = new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", +}); + +export function formatPrice(price, defaultValue = undefined) { + if (!price || price === "0") { + if (defaultValue !== undefined) + return defaultValue; + else + price = 0; + } + return priceFormatter.format(price); +} + +export function formatDate(date, variant = "date") { + if (typeof date === "string") date = parseISO(date); + const options = { locale: fr }; + switch (variant) { + case "date": + return lightFormat(date, "dd/MM/yyyy", options); + case "datetime": + return lightFormat(date, "dd/MM/yyyy HH:mm", options); + case "datetimesec": + return lightFormat(date, "dd/MM/yyyy HH:mm:ss", options); + case "fromNow": + return formatDistanceToNow(date, options); + case "fromNowStrict": + return formatDistanceToNowStrict(date, options); + default: + throw Error(`Unknown format '${variant}'`); + } +} diff --git a/src/utils/helpers.js b/src/utils/helpers.js new file mode 100644 index 0000000..dc58c1c --- /dev/null +++ b/src/utils/helpers.js @@ -0,0 +1,79 @@ + +export function isObject(object) { + return typeof object === 'object' && object !== null && !isList(object); +} + +export function isList(object) { + return object && object.length !== undefined; +} + +export function isEmpty(object) { + return object == null || (object instanceof Object && Object.keys(object).length === 0); +} + +export function deepcopy(object) { + return JSON.parse(JSON.stringify(object)); +} + +// ------------------------------------------------- +// Mappers +// ------------------------------------------------- + +export function stringToGetter(attr) { + return obj => obj[attr]; +} + +export function arrayToMap(array, getKey, getValue) { + if (typeof getKey === 'string') + getKey = stringToGetter(getKey); + if (typeof getValue === 'string') + getValue = stringToGetter(getValue); + + return array.reduce((map, obj) => { + map[getKey(obj)] = getValue ? getValue(obj) : obj; + return map; + }, {}); +} + +export function dataToChoices(data, labelKey) { + return Object.values(data).reduce((choices, object) => { + choices[object.id] = { + value: object.id, + label: object[labelKey], + }; + return choices; + }, {}); +} + +// ------------------------------------------------- +// Comparison +// ------------------------------------------------- + +export function goDeep(path, ...args) { + return path ? path.split('.').reduce((dataArr, step) => ( + dataArr.map(data => data[step]) + ), args) : args; +} + +export function getDifferentChildren(prevData, nextData, path=null) { + if (path) + [prevData, nextData] = goDeep(path, prevData, nextData); + return Object.keys(nextData).reduce((store, key) => { + if (key in prevData) + store[prevData[key] === nextData[key] ? 'same' : 'updated'].push(key); + else + store.added.push(key); + return store; + }, { + added: [], + updated: [], + same: [], + deleted: Object.keys(prevData).filter(key => !(key in nextData)), + }); +} + +export function areDifferent(a, b, path=null) { + if (path) + [a, b] = goDeep(path, a, b); + return a !== b || JSON.stringify(a) !== JSON.stringify(b); +} diff --git a/src/utils/styles.js b/src/utils/styles.js new file mode 100644 index 0000000..daed87c --- /dev/null +++ b/src/utils/styles.js @@ -0,0 +1,76 @@ +import { makeStyles } from "@material-ui/core/styles"; +import { fade } from "@material-ui/core/styles/colorManipulator"; + + +export function mergeClasses(classes, ...names) { + return names.reduce((merged, name) => ( + name in classes ? `${merged} ${classes[name]}` : merged + ), "").slice(1); +} + +export function getButtonColoredVariant(theme, color, variant) { + const colors = theme.palette[color]; + switch (variant) { + case "outlined": + return { + color: colors.main, + border: `1px solid ${fade(colors.main, 0.5)}`, + "&:hover": { + border: `1px solid ${colors.main}`, + backgroundColor: fade(colors.main, theme.palette.action.hoverOpacity), + "@media (hover: none)": { + backgroundColor: "transparent", + }, + }, + }; + case "contained": + return { + color: colors.contrastText, + backgroundColor: colors.main, + "&:hover": { + backgroundColor: colors.dark, + "@media (hover: none)": { + backgroundColor: colors.main, + }, + }, + }; + default: + case "text": + return { + color: colors.main, + "&:hover": { + backgroundColor: fade(colors.main, theme.palette.action.hoverOpacity), + "@media (hover: none)": { + backgroundColor: "transparent", + }, + }, + }; + } +} + +// Style decorators + +export const useFormStyles = makeStyles((theme) => ({ + column: { + display: "flex", + flexDirection: "column", + paddingTop: "0 !important", + paddingBottom: "0 !important", + minWidth: 220, + }, + controls: { + maxWidth: 280, + }, + editor: { + // borderWidth: 1, + // borderStyle: "solid", + // borderColor: "transparent", + padding: theme.spacing(2), + }, + editing: { + borderColor: "yellow", + }, + error: { + borderColor: "red", + }, +}));
+ {shorten(sale.description, 150)} +
- L'attribut fun_id de l'association n'est pas défini. - Veuillez contacter un administrateur pour terminer la configuration. -
fun_id
À venir...
{sale.description}