diff --git a/apps/cms/package.json b/apps/cms/package.json index 73f72358..cbcffe77 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -15,16 +15,23 @@ "generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema" }, "dependencies": { + "@mui/icons-material": "^5.11.9", + "@mui/material": "^5.11.9", + "apexcharts": "^3.37.0", "dotenv": "^8.2.0", "express": "^4.17.1", "payload": "^1.3.4", "react": "^18.0.0", + "react-apexcharts": "^1.4.0", + "react-perfect-scrollbar": "^1.5.8", "tsconfig": "*" }, "devDependencies": { "@types/express": "^4.17.9", + "axios": "^1.3.4", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", + "jest": "^29.5.0", "nodemon": "^2.0.6", "ts-node": "^9.1.1", "tsconfig": "*", diff --git a/apps/cms/src/__mocks__/axios.tsx b/apps/cms/src/__mocks__/axios.tsx new file mode 100644 index 00000000..7a6e4674 --- /dev/null +++ b/apps/cms/src/__mocks__/axios.tsx @@ -0,0 +1,23 @@ +const mockResponse = { + data: { + results: [ + { + name: { + first: "Laith", + last: "Harb" + }, + picture: { + large: "https://randomuser.me/api/portraits/men/59.jpg" + }, + login: { + username: "ThePhonyGOAT" + } + } + ] + } +} + + +export default { + get: jest.fn().mockResolvedValue(mockResponse) +} \ No newline at end of file diff --git a/apps/cms/src/admin/components/Budget.tsx b/apps/cms/src/admin/components/Budget.tsx new file mode 100644 index 00000000..52c92f47 --- /dev/null +++ b/apps/cms/src/admin/components/Budget.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { Avatar, Box, Card, CardContent, Grid, Typography } from '@mui/material'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import MoneyIcon from '@mui/icons-material/Money'; + +const Budget = (props) => { + return ( + + + + + + BUDGET + + + $24k + + + + + + + + + + + + 12% + + + Since last month + + + + + + ); +} + +export default Budget; \ No newline at end of file diff --git a/apps/cms/src/admin/components/LatestOrder.tsx b/apps/cms/src/admin/components/LatestOrder.tsx new file mode 100644 index 00000000..6a97149f --- /dev/null +++ b/apps/cms/src/admin/components/LatestOrder.tsx @@ -0,0 +1,168 @@ +import React from "react"; +import { format } from 'date-fns'; +import { v4 as uuid } from 'uuid'; +import PerfectScrollbar from 'react-perfect-scrollbar'; +import { + Box, + Button, + Card, + CardHeader, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TableSortLabel, + Tooltip +} from '@mui/material'; +import ArrowRightIcon from '@mui/icons-material/ArrowRight'; +import { SeverityPill } from './SeverityPill'; + +const orders = [ + { + id: uuid(), + ref: 'Merch 1', + amount: 30.5, + customer: { + name: 'Ekaterina Tankova' + }, + createdAt: 1555016400000, + status: 'pending' + }, + { + id: uuid(), + ref: 'Merch 2', + amount: 25.1, + customer: { + name: 'Cao Yu' + }, + createdAt: 1555016400000, + status: 'delivered' + }, + { + id: uuid(), + ref: 'Merch 3', + amount: 10.99, + customer: { + name: 'Alexa Richardson' + }, + createdAt: 1554930000000, + status: 'refunded' + }, + { + id: uuid(), + ref: 'Merch 4', + amount: 96.43, + customer: { + name: 'Anje Keizer' + }, + createdAt: 1554757200000, + status: 'pending' + }, + { + id: uuid(), + ref: 'Merch 5', + amount: 32.54, + customer: { + name: 'Clarke Gillebert' + }, + createdAt: 1554670800000, + status: 'delivered' + }, + { + id: uuid(), + ref: 'Merch 5', + amount: 16.76, + customer: { + name: 'Adam Denisov' + }, + createdAt: 1554670800000, + status: 'delivered' + } + ]; + + +const LatestOrder = (props) => { + return ( + + + + + + + + + Merch Ordered + + + Customer + + + + + Date + + + + + Status + + + + + {orders.map((order) => ( + + + {order.ref} + + + {order.customer.name} + + + {format(order.createdAt, 'dd/MM/yyyy')} + + + + {order.status} + + + + ))} + + + + + + } + size="small" + variant="text" + > + View all + + + + ); +} + +export default LatestOrder; \ No newline at end of file diff --git a/apps/cms/src/admin/components/LineChart.tsx b/apps/cms/src/admin/components/LineChart.tsx new file mode 100644 index 00000000..cae80a8a --- /dev/null +++ b/apps/cms/src/admin/components/LineChart.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import useChart from './UseChart'; + +import dynamic from 'next/dynamic' +const ReactApexChart = dynamic(() => import('react-apexcharts'), { ssr: false }); + + +import {Card, CardHeader, Box} from '@mui/material'; + +const LineChart = ({title, subheader, chartLabels, chartData, ...other}) => { + + const chartOptions = useChart({ + plotOptions: { bar: { columnWidth: '16%' } }, + fill: { type: chartData.map((i) => i.fill) }, + labels: chartLabels, + xaxis: { type: 'datetime' }, + tooltip: { + shared: true, + intersect: false, + y: { + formatter: (y) => { + if (typeof y !== 'undefined') { + return `${y.toFixed(0)} sales`; + } + return y; + }, + }, + }, + }); + + return ( + + + + + + + + ); +} + +export default LineChart; \ No newline at end of file diff --git a/apps/cms/src/admin/components/SeverityPill.tsx b/apps/cms/src/admin/components/SeverityPill.tsx new file mode 100644 index 00000000..e3049540 --- /dev/null +++ b/apps/cms/src/admin/components/SeverityPill.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import PropTypes from 'prop-types'; +import { styled } from '@mui/material/styles'; + +const SeverityPillRoot = styled('span')(({ theme }) => { +// const backgroundColor = theme.palette[ownerState.color].main; +// const color = theme.palette[ownerState.color].contrastText; + + return { + alignItems: 'center', + // backgroundColor, + borderRadius: 12, + // color, + cursor: 'default', + display: 'inline-flex', + flexGrow: 0, + flexShrink: 0, + fontFamily: theme.typography.fontFamily, + fontSize: theme.typography.pxToRem(12), + lineHeight: 2, + fontWeight: 600, + justifyContent: 'center', + letterSpacing: 0.5, + minWidth: 20, + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + textTransform: 'uppercase', + whiteSpace: 'nowrap' + }; +}); + +export const SeverityPill = (props) => { + const { color = 'primary', children, ...other } = props; + + const ownerState = { color }; + + return ( + + {children} + + ); +}; + +SeverityPill.propTypes = { + children: PropTypes.node, + color: PropTypes.oneOf([ + 'primary', + 'secondary', + 'error', + 'info', + 'warning', + 'success' + ]) +}; diff --git a/apps/cms/src/admin/components/TotalCustomers.tsx b/apps/cms/src/admin/components/TotalCustomers.tsx new file mode 100644 index 00000000..a79230e6 --- /dev/null +++ b/apps/cms/src/admin/components/TotalCustomers.tsx @@ -0,0 +1,71 @@ +import React from "react"; + +import { Avatar, Box, Card, CardContent, Grid, Typography } from '@mui/material'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import PeopleIcon from '@mui/icons-material/PeopleOutlined'; + +const TotalCustomers = (props) => { + return ( + + + + + + TOTAL CUSTOMERS + + + {props.orderNum} + + + + + + + + + + + + 16% + + + Since last month + + + + + ); +} + +export default TotalCustomers; \ No newline at end of file diff --git a/apps/cms/src/admin/components/TotalProfit.tsx b/apps/cms/src/admin/components/TotalProfit.tsx new file mode 100644 index 00000000..9edfa719 --- /dev/null +++ b/apps/cms/src/admin/components/TotalProfit.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Avatar, Card, CardContent, Grid, Typography } from '@mui/material'; +import AttachMoneyIcon from '@mui/icons-material/AttachMoney'; + +const TotalProfit = (props) => { + return ( + + + + + + TOTAL PROFIT + + + {props.totalProfit} + + + + + + + + + + + ); +} + +export default TotalProfit; \ No newline at end of file diff --git a/apps/cms/src/admin/components/UseChart.tsx b/apps/cms/src/admin/components/UseChart.tsx new file mode 100644 index 00000000..de540669 --- /dev/null +++ b/apps/cms/src/admin/components/UseChart.tsx @@ -0,0 +1,198 @@ +import merge from 'lodash/merge'; +import { useTheme, alpha } from '@mui/material/styles'; + + +export default function useChart(options) { + const theme = useTheme(); + + const LABEL_TOTAL = { + show: true, + label: 'Total', + color: theme.palette.text.secondary, + fontSize: theme.typography.subtitle2.fontSize, + fontWeight: theme.typography.subtitle2.fontWeight, + lineHeight: theme.typography.subtitle2.lineHeight, + }; + + const LABEL_VALUE = { + offsetY: 8, + color: theme.palette.text.primary, + fontSize: theme.typography.h3.fontSize, + fontWeight: theme.typography.h3.fontWeight, + lineHeight: theme.typography.h3.lineHeight, + }; + + const baseOptions = { + // Colors + colors: [ + theme.palette.primary.main, + theme.palette.warning.main, + theme.palette.info.main, + theme.palette.error.main, + theme.palette.success.main, + theme.palette.warning.dark, + theme.palette.success.dark, + theme.palette.info.dark, + theme.palette.info.dark, + ], + + // Chart + chart: { + toolbar: { show: false }, + zoom: { enabled: false }, + // animations: { enabled: false }, + foreColor: theme.palette.text.disabled, + fontFamily: theme.typography.fontFamily, + }, + + // States + states: { + hover: { + filter: { + type: 'lighten', + value: 0.04, + }, + }, + active: { + filter: { + type: 'darken', + value: 0.88, + }, + }, + }, + + // Fill + fill: { + opacity: 1, + gradient: { + type: 'vertical', + shadeIntensity: 0, + opacityFrom: 0.4, + opacityTo: 0, + stops: [0, 100], + }, + }, + + // Datalabels + dataLabels: { enabled: false }, + + // Stroke + stroke: { + width: 3, + curve: 'smooth', + lineCap: 'round', + }, + + // Grid + grid: { + strokeDashArray: 3, + borderColor: theme.palette.divider, + }, + + // Xaxis + xaxis: { + axisBorder: { show: false }, + axisTicks: { show: false }, + }, + + // Markers + markers: { + size: 0, + strokeColors: theme.palette.background.paper, + }, + + // Tooltip + tooltip: { + x: { + show: false, + }, + }, + + // Legend + legend: { + show: true, + fontSize: String(13), + position: 'top', + horizontalAlign: 'right', + markers: { + radius: 12, + }, + fontWeight: 500, + itemMargin: { horizontal: 12 }, + labels: { + colors: theme.palette.text.primary, + }, + }, + + // plotOptions + plotOptions: { + // Bar + bar: { + borderRadius: 4, + columnWidth: '28%', + }, + + // Pie + Donut + pie: { + donut: { + labels: { + show: true, + value: LABEL_VALUE, + total: LABEL_TOTAL, + }, + }, + }, + + // Radialbar + radialBar: { + track: { + strokeWidth: '100%', + background: alpha(theme.palette.grey[500], 0.16), + }, + dataLabels: { + value: LABEL_VALUE, + total: LABEL_TOTAL, + }, + }, + + // Radar + radar: { + polygons: { + fill: { colors: ['transparent'] }, + strokeColors: theme.palette.divider, + connectorColors: theme.palette.divider, + }, + }, + + // polarArea + polarArea: { + rings: { + strokeColor: theme.palette.divider, + }, + spokes: { + connectorColors: theme.palette.divider, + }, + }, + }, + + // Responsive + responsive: [ + { + // sm + breakpoint: theme.breakpoints.values.sm, + options: { + plotOptions: { bar: { columnWidth: '40%' } }, + }, + }, + { + // md + breakpoint: theme.breakpoints.values.md, + options: { + plotOptions: { bar: { columnWidth: '32%' } }, + }, + }, + ], + }; + + return merge(baseOptions, options); +} diff --git a/apps/cms/src/admin/views/MerchSales.tsx b/apps/cms/src/admin/views/MerchSales.tsx index 35bfbc15..ddc5a4dd 100644 --- a/apps/cms/src/admin/views/MerchSales.tsx +++ b/apps/cms/src/admin/views/MerchSales.tsx @@ -1,9 +1,213 @@ -import React from "react"; +import React, {useEffect, useState, useLayoutEffect} from "react"; import { Button } from 'payload/components/elements'; import { AdminView } from 'payload/config'; import ViewTemplate from "./ViewTemplate"; +import LatestOrder from "../components/LatestOrder"; +import TotalCustomers from "../components/TotalCustomers"; +import TotalProfit from "../components/TotalProfit"; +import Budget from "../components/Budget"; +import LineChart from "../components/LineChart"; +import {Box, Container, Grid} from '@mui/material'; + +// jest.mock('axios') const MerchSales: AdminView = ({ user, canAccessAdmin }) => { + + const [totalProfit, setTotalProfit] = useState("") + const [merchList, setMerchList] = useState({}) + const [orderNum, setOrderNum] = useState(0) + + const[chartLabels, setChartLabels] = useState([]) + const[merchNum, setMerchNum] = useState({}) + + + function formatMerchList(orders){ + + orders['orders'].forEach((o)=>{ + let [orderItems] = o.orderItems + if(!merchList.hasOwnProperty(orderItems.id)) + setMerchList(merchList[orderItems.id] = orderItems.name) + }) + + + } + + function formatTotalProfit(totalProfit){ + return '$' + totalProfit/100 + } + + function formatOrderNum(order){ + return order['orders'].length + } + + + function formatOrderGrouped(orders){ + let months = Object.entries(orders['orders'].reduce((b, a) => { + + let monthYear = a.orderDateTime.substr(0,11) + let [merchID] = a.orderItems + + + if (!b.hasOwnProperty(monthYear)) b[monthYear] = {}; + + if(b[monthYear].hasOwnProperty(merchID.id)) + b[monthYear][merchID.id] += 1; + else + b[monthYear][merchID.id] = 1; + + return b; }, [])) + .sort((a,b) => a[0].localeCompare(b[0])) + .map(e => ({[e[0]]:e[1]})); + + + let chart = [] + months.forEach((item)=>{ + let [key] = Object.keys(item) + + setChartLabels([...chartLabels, chartLabels.push(key.slice(5,7).concat( "/",key.slice(8,10), "/", key.slice(0,4)))]) + }) + + let merchIDList = Object.keys(merchList); + let merchNum = {} + + merchIDList.forEach((key)=>{ + if(!merchNum.hasOwnProperty(key)) + merchNum[key] = []; + + months.forEach((date)=>{ + let [d] = Object.keys(date) + if(date[d].hasOwnProperty(key)) + setMerchNum(merchNum[key].push(date[d][key])) + else + setMerchNum(merchNum[key].push(0)) + + }) + + }) + + console.log(merchNum) + } + + + useEffect(() => { + + //axios get totalRevenue + const totalProfit = 13000 + + setTotalProfit(formatTotalProfit(totalProfit)) + + //axios get orders + const orders = { + orders: [ + { + "orderDateTime": "2023-02-22 13:52:45.335980", + "orderID": "b431a8dd-730e-4caa-9783-b807d4bb1068", + "customerEmail": "a@a.com", + "orderItems": [ + { + "image": "https://cdn.ntuscse.com/merch/products/images/2022_001_01.jpeg", + "quantity": 1, + "size": "XS", + "price": 1200, + "name": "SCSE Standard T-shirt", + "colorway": "Black", + "id": "2022_001", + "product_category": "T-shirt" + } + ], + "transactionID": "pi_3MeIkTI2fddOVwLc0S3J9ioj", + "paymentGateway": "stripe", + "status": 1 + }, + { + "orderDateTime": "2023-02-20 13:52:45.335980", + "orderID": "b431a8dd-730e-4caa-9783-b807d4bb1068", + "customerEmail": "a1@a.com", + "orderItems": [ + { + "image": "https://cdn.ntuscse.com/merch/products/images/2022_001_01.jpeg", + "quantity": 1, + "size": "XS", + "price": 1200, + "name": "SCSE Standard T-shirt", + "colorway": "Black", + "id": "2022_001", + "product_category": "T-shirt" + } + ], + "transactionID": "pi_3MeIkTI2fddOVwLc0S3J9ioj", + "paymentGateway": "stripe", + "status": 1 + }, + { + "orderDateTime": "2023-03-22 13:52:45.335980", + "orderID": "b431a8dd-730e-4caa-9783-b807d4bb1068", + "customerEmail": "a@a.com", + "orderItems": [ + { + "image": "https://cdn.ntuscse.com/merch/products/images/2022_001_01.jpeg", + "quantity": 1, + "size": "XS", + "price": 1200, + "name": "SCSE Standard T-shirt", + "colorway": "Black", + "id": "2022_001", + "product_category": "T-shirt" + } + ], + "transactionID": "pi_3MeIkTI2fddOVwLc0S3J9ioj", + "paymentGateway": "stripe", + "status": 1 + }, + { + "orderDateTime": "2022-02-22 13:52:45.335980", + "orderID": "b431a8dd-730e-4caa-9783-b807d4bb1068", + "customerEmail": "a3@a.com", + "orderItems": [ + { + "image": "https://cdn.ntuscse.com/merch/products/images/2022_001_01.jpeg", + "quantity": 1, + "size": "XS", + "price": 1200, + "name": "SCSE Standard T-shirt", + "colorway": "Black", + "id": "2022_002", + "product_category": "T-shirt" + } + ], + "transactionID": "pi_3MeIkTI2fddOVwLc0S3J9ioj", + "paymentGateway": "stripe", + "status": 1 + } + + ] + } + + formatMerchList(orders) + setOrderNum(formatOrderNum(orders)) + formatOrderGrouped(orders) + + console.log(merchNum) + + + }, []); + + function chartDataOneMerch(merchNum, merchList){ + let chartData = [] + let keyMerchList = Object.keys(merchList) + keyMerchList.forEach((merchKey)=>{ + chartData.push({ + name: merchList[merchKey], + type: 'line', + fill: 'solid', + data: merchNum[merchKey], + }) + }) + + return chartData + + } + return ( { keywords="" title="Merchandise Sales" > - Here is a custom route that was added in the Payload config. It uses the Default Template, so the sidebar is rendered. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + =2.3.x" + +svg.filter.js@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/svg.filter.js/-/svg.filter.js-2.0.2.tgz#91008e151389dd9230779fcbe6e2c9a362d1c203" + integrity sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw== + dependencies: + svg.js "^2.2.5" + +svg.js@>=2.3.x, svg.js@^2.0.1, svg.js@^2.2.5, svg.js@^2.4.0, svg.js@^2.6.5: + version "2.7.1" + resolved "https://registry.yarnpkg.com/svg.js/-/svg.js-2.7.1.tgz#eb977ed4737001eab859949b4a398ee1bb79948d" + integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA== + +svg.pathmorphing.js@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz#c25718a1cc7c36e852ecabc380e758ac09bb2b65" + integrity sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww== + dependencies: + svg.js "^2.4.0" + +svg.resize.js@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/svg.resize.js/-/svg.resize.js-1.4.3.tgz#885abd248e0cd205b36b973c4b578b9a36f23332" + integrity sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw== + dependencies: + svg.js "^2.6.5" + svg.select.js "^2.1.2" + +svg.select.js@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-2.1.2.tgz#e41ce13b1acff43a7441f9f8be87a2319c87be73" + integrity sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ== + dependencies: + svg.js "^2.2.5" + +svg.select.js@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-3.0.1.tgz#a4198e359f3825739226415f82176a90ea5cc917" + integrity sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw== + dependencies: + svg.js "^2.6.5" + svgo@^2.7.0: version "2.8.0" resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24" @@ -18319,7 +19160,7 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" -write-file-atomic@^4.0.1: +write-file-atomic@^4.0.1, write-file-atomic@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==
Here is a custom route that was added in the Payload config. It uses the Default Template, so the sidebar is rendered.