diff --git a/03_source/frontend/src/_mock/_party-event.ts b/03_source/frontend/src/_mock/_party-event.ts index 1b8cdab..73cc98e 100644 --- a/03_source/frontend/src/_mock/_party-event.ts +++ b/03_source/frontend/src/_mock/_party-event.ts @@ -1,14 +1,14 @@ -export const PRODUCT_GENDER_OPTIONS = [ +export const PARTY_EVENT_GENDER_OPTIONS = [ { label: 'Men', value: 'Men' }, { label: 'Women', value: 'Women' }, { label: 'Kids', value: 'Kids' }, ]; -export const PRODUCT_CATEGORY_OPTIONS = ['Shose', 'Apparel', 'Accessories']; +export const PARTY_EVENT_CATEGORY_OPTIONS = ['Shose', 'Apparel', 'Accessories']; -export const PRODUCT_RATING_OPTIONS = ['up4Star', 'up3Star', 'up2Star', 'up1Star']; +export const PARTY_EVENT_RATING_OPTIONS = ['up4Star', 'up3Star', 'up2Star', 'up1Star']; -export const PRODUCT_COLOR_OPTIONS = [ +export const PARTY_EVENT_COLOR_OPTIONS = [ '#FF4842', '#1890FF', '#FFC0CB', @@ -19,7 +19,7 @@ export const PRODUCT_COLOR_OPTIONS = [ '#FFFFFF', ]; -export const PRODUCT_COLOR_NAME_OPTIONS = [ +export const PARTY_EVENT_COLOR_NAME_OPTIONS = [ { value: '#FF4842', label: 'Red' }, { value: '#1890FF', label: 'Blue' }, { value: '#FFC0CB', label: 'Pink' }, @@ -30,7 +30,7 @@ export const PRODUCT_COLOR_NAME_OPTIONS = [ { value: '#FFFFFF', label: 'White' }, ]; -export const PRODUCT_SIZE_OPTIONS = [ +export const PARTY_EVENT_SIZE_OPTIONS = [ { value: '7', label: '7' }, { value: '8', label: '8' }, { value: '8.5', label: '8.5' }, @@ -44,26 +44,26 @@ export const PRODUCT_SIZE_OPTIONS = [ { value: '13', label: '13' }, ]; -export const PRODUCT_STOCK_OPTIONS = [ +export const PARTY_EVENT_STOCK_OPTIONS = [ { value: 'in stock', label: 'In stock' }, { value: 'low stock', label: 'Low stock' }, { value: 'out of stock', label: 'Out of stock' }, ]; // not used due to i18n -export const PRODUCT_PUBLISH_OPTIONS = [ +export const PARTY_EVENT_PUBLISH_OPTIONS = [ { value: 'published', label: 'Published' }, { value: 'draft', label: 'Draft' }, ]; -export const PRODUCT_SORT_OPTIONS = [ +export const PARTY_EVENT_SORT_OPTIONS = [ { value: 'featured', label: 'Featured' }, { value: 'newest', label: 'Newest' }, { value: 'priceDesc', label: 'Price: High - Low' }, { value: 'priceAsc', label: 'Price: Low - High' }, ]; -export const PRODUCT_CATEGORY_GROUP_OPTIONS = [ +export const PARTY_EVENT_CATEGORY_GROUP_OPTIONS = [ { group: 'Clothing', classify: ['Shirts', 'T-shirts', 'Jeans', 'Leather', 'Accessories'] }, { group: 'Tailored', classify: ['Suits', 'Blazers', 'Trousers', 'Waistcoats', 'Apparel'] }, { group: 'Accessories', classify: ['Shoes', 'Backpacks and bags', 'Bracelets', 'Face masks'] }, diff --git a/03_source/frontend/src/_mock/_party-order.ts b/03_source/frontend/src/_mock/_party-order.ts new file mode 100644 index 0000000..1b11276 --- /dev/null +++ b/03_source/frontend/src/_mock/_party-order.ts @@ -0,0 +1,89 @@ +import { _mock } from './_mock'; + +// ---------------------------------------------------------------------- + +export const PARTY_ORDER_STATUS_OPTIONS = [ + { value: 'pending', label: 'Pending' }, + { value: 'completed', label: 'Completed' }, + { value: 'cancelled', label: 'Cancelled' }, + { value: 'refunded', label: 'Refunded' }, +]; + +const ITEMS = Array.from({ length: 3 }, (_, index) => ({ + id: _mock.id(index), + sku: `16H9UR${index}`, + quantity: index + 1, + name: _mock.productName(index), + coverUrl: _mock.image.product(index), + price: _mock.number.price(index), +})); + +export const _party_orders = Array.from({ length: 20 }, (_, index) => { + const shipping = 10; + + const discount = 10; + + const taxes = 10; + + const items = (index % 2 && ITEMS.slice(0, 1)) || (index % 3 && ITEMS.slice(1, 3)) || ITEMS; + + const totalQuantity = items.reduce((accumulator, item) => accumulator + item.quantity, 0); + + const subtotal = items.reduce((accumulator, item) => accumulator + item.price * item.quantity, 0); + + const totalAmount = subtotal - shipping - discount + taxes; + + const customer = { + id: _mock.id(index), + name: _mock.fullName(index), + email: _mock.email(index), + avatarUrl: _mock.image.avatar(index), + ipAddress: '192.158.1.38', + }; + + const delivery = { shipBy: 'DHL', speedy: 'Standard', trackingNumber: 'SPX037739199373' }; + + const history = { + orderTime: _mock.time(1), + paymentTime: _mock.time(2), + deliveryTime: _mock.time(3), + completionTime: _mock.time(4), + timeline: [ + { title: 'Delivery successful', time: _mock.time(1) }, + { title: 'Transporting to [2]', time: _mock.time(2) }, + { title: 'Transporting to [1]', time: _mock.time(3) }, + { title: 'The shipping unit has picked up the goods', time: _mock.time(4) }, + { title: 'Order has been created', time: _mock.time(5) }, + ], + }; + + return { + id: _mock.id(index), + orderNumber: `#601${index}`, + createdAt: _mock.time(index), + taxes, + items, + history, + subtotal, + shipping, + discount, + customer, + delivery, + totalAmount, + totalQuantity, + shippingAddress: { + fullAddress: '19034 Verna Unions Apt. 164 - Honolulu, RI / 87535', + phoneNumber: '365-374-4961', + }, + payment: { + // + cardType: 'mastercard', + cardNumber: '**** **** **** 5678', + }, + status: + (index % 2 && 'completed') || + (index % 3 && 'pending') || + (index % 4 && 'cancelled') || + 'refunded', + }; +}); diff --git a/03_source/frontend/src/_mock/index.ts b/03_source/frontend/src/_mock/index.ts index 9685023..4bd8e34 100644 --- a/03_source/frontend/src/_mock/index.ts +++ b/03_source/frontend/src/_mock/index.ts @@ -23,3 +23,7 @@ export * from './_product'; export * from './_overview'; export * from './_calendar'; + +export * from './_party-event'; + +export * from './_party-order'; diff --git a/03_source/frontend/src/actions/party-order.ts b/03_source/frontend/src/actions/party-order.ts new file mode 100644 index 0000000..662e519 --- /dev/null +++ b/03_source/frontend/src/actions/party-order.ts @@ -0,0 +1,226 @@ +// src/actions/order.ts +import { useMemo } from 'react'; +import axiosInstance, { endpoints, fetcher } from 'src/lib/axios'; +import type { IOrderItem } from 'src/types/party-order'; +import type { IProductItem } from 'src/types/product'; +import type { SWRConfiguration } from 'swr'; +import useSWR from 'swr'; + +// ---------------------------------------------------------------------- + +const swrOptions: SWRConfiguration = { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, +}; + +// ---------------------------------------------------------------------- + +type OrdersData = { + partyOrders: IOrderItem[]; +}; + +export function useGetPartyOrders() { + const url = endpoints.partyOrder.list; + + const { data, isLoading, error, isValidating, mutate } = useSWR( + url, + fetcher, + swrOptions + ); + + const memoizedValue = useMemo( + () => ({ + orders: data?.partyOrders || [], + ordersLoading: isLoading, + ordersError: error, + ordersValidating: isValidating, + ordersEmpty: !isLoading && !isValidating && !data?.partyOrders.length, + mutate, + }), + [data?.partyOrders, error, isLoading, isValidating, mutate] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type OrderData = { + order: IOrderItem; +}; + +export function useGetOrder(orderId: string) { + const url = orderId ? [endpoints.order.details, { params: { orderId } }] : ''; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, swrOptions); + + const memoizedValue = useMemo( + () => ({ + order: data?.order, + orderLoading: isLoading, + orderError: error, + orderValidating: isValidating, + }), + [data?.order, error, isLoading, isValidating] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type SearchResultsData = { + results: IProductItem[]; +}; + +export function useSearchProducts(query: string) { + const url = query ? [endpoints.product.search, { params: { query } }] : ''; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, { + ...swrOptions, + keepPreviousData: true, + }); + + const memoizedValue = useMemo( + () => ({ + searchResults: data?.results || [], + searchLoading: isLoading, + searchError: error, + searchValidating: isValidating, + searchEmpty: !isLoading && !isValidating && !data?.results.length, + }), + [data?.results, error, isLoading, isValidating] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type SaveOrderData = { + name: string; + city: string; + role: string; + email: string; + state: string; + status: string; + address: string; + country: string; + zipCode: string; + company: string; + avatarUrl: string; + phoneNumber: string; + isVerified: boolean; + // + ordername: string; + password: string; +}; + +export async function saveOrder(orderId: string, saveOrderData: SaveOrderData) { + // const url = orderId ? [endpoints.order.details, { params: { orderId } }] : ''; + + const res = await axiosInstance.post( + // + `http://localhost:7272/api/order/saveOrder?orderId=${orderId}`, + { + data: saveOrderData, + } + ); + + return res; +} + +export async function uploadOrderImage(saveOrderData: SaveOrderData) { + console.log('uploadOrderImage ?'); + // const url = orderId ? [endpoints.order.details, { params: { orderId } }] : ''; + + const res = await axiosInstance.get('http://localhost:7272/api/product/helloworld'); + + return res; +} + +// ---------------------------------------------------------------------- + +type CreateOrderData = { + name: string; + city: string; + role: string; + email: string; + state: string; + status: string; + address: string; + country: string; + zipCode: string; + company: string; + avatarUrl: string; + phoneNumber: string; + isVerified: boolean; + // + ordername: string; + password: string; +}; + +export async function createOrder(createOrderData: CreateOrderData) { + console.log('create product ?'); + // const url = productId ? [endpoints.product.details, { params: { productId } }] : ''; + + const res = await axiosInstance.post('http://localhost:7272/api/order/createOrder', { + data: createOrderData, + }); + + return res; +} + +// ---------------------------------------------------------------------- + +type DeleteOrderResponse = { + success: boolean; + message?: string; +}; + +export async function deletePartyOrder(orderId: string): Promise { + const url = `http://localhost:7272/api/order/deleteOrder?orderId=${orderId}`; + + try { + const res = await axiosInstance.delete(url); + + return { + success: true, + message: 'Order deleted successfully', + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to delete product', + }; + } +} + +// ---------------------------------------------------------------------- + +type ChangeStatusResponse = { + success: boolean; + message?: string; +}; + +export async function changeStatus( + orderId: string, + newOrderStatus: string +): Promise { + const url = endpoints.order.changeStatus(orderId); + + try { + const res = await axiosInstance.put(url, { data: { status: newOrderStatus } }); + + return { + success: true, + message: 'status updated successfully', + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to delete product', + }; + } +} diff --git a/03_source/frontend/src/lib/axios.ts b/03_source/frontend/src/lib/axios.ts index 64ba133..922153a 100644 --- a/03_source/frontend/src/lib/axios.ts +++ b/03_source/frontend/src/lib/axios.ts @@ -84,6 +84,9 @@ export const endpoints = { changeStatus: (invoiceId: string) => `/api/invoice/changeStatus?invoiceId=${invoiceId}`, search: '/api/invoice/search', }, + // + // + // partyEvent: { list: '/api/party-event/list', details: '/api/party-event/details', @@ -92,4 +95,13 @@ export const endpoints = { update: '/api/party-event/update', delete: '/api/party-event/delete', }, + partyOrder: { + list: '/api/party-order/list', + profile: '/api/party-order/profile', + update: '/api/party-order/update', + settings: '/api/party-order/settings', + details: '/api/party-order/details', + changeStatus: (partyOrderId: string) => + `/api/party-order/changeStatus?partyOrderId=${partyOrderId}`, + }, }; diff --git a/03_source/frontend/src/pages/dashboard/party-order/details.tsx b/03_source/frontend/src/pages/dashboard/party-order/details.tsx new file mode 100644 index 0000000..f128574 --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-order/details.tsx @@ -0,0 +1,30 @@ +// src/pages/dashboard/order/details.tsx +// +import { _orders } from 'src/_mock/_order'; +import { useGetOrder } from 'src/actions/order'; +import { CONFIG } from 'src/global-config'; +import { useParams } from 'src/routes/hooks'; +import { OrderDetailsView } from 'src/sections/order/view'; + +// ---------------------------------------------------------------------- + +const metadata = { title: `Order details | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + const { id = '' } = useParams(); + + // const currentOrder = _orders.find((order) => order.id === id); + // TODO: error handling + const { order, orderLoading, orderError } = useGetOrder(id); + + if (!order) return <>loading; + if (orderLoading) return <>loading; + + return ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/dashboard/party-order/list.tsx b/03_source/frontend/src/pages/dashboard/party-order/list.tsx new file mode 100644 index 0000000..17c0612 --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-order/list.tsx @@ -0,0 +1,18 @@ +// src/pages/dashboard/party-order/list.tsx +// +import { CONFIG } from 'src/global-config'; +import { PartyOrderListView } from 'src/sections/party-order/view'; + +// ---------------------------------------------------------------------- + +const metadata = { title: `Order list | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/routes/paths.ts b/03_source/frontend/src/routes/paths.ts index 902c0a8..66d237e 100644 --- a/03_source/frontend/src/routes/paths.ts +++ b/03_source/frontend/src/routes/paths.ts @@ -179,6 +179,8 @@ export const paths = { }, }, // + // + // partyEvent: { root: `${ROOTS.DASHBOARD}/party-event`, new: `${ROOTS.DASHBOARD}/party-event/new`, @@ -189,5 +191,10 @@ export const paths = { edit: `${ROOTS.DASHBOARD}/party-event/${MOCK_ID}/edit`, }, }, + partyOrder: { + root: `${ROOTS.DASHBOARD}/party-order`, + details: (id: string) => `${ROOTS.DASHBOARD}/party-order/${id}`, + demo: { details: `${ROOTS.DASHBOARD}/party-order/${MOCK_ID}` }, + }, }, }; diff --git a/03_source/frontend/src/routes/sections/dashboard.tsx b/03_source/frontend/src/routes/sections/dashboard.tsx index 988c89b..f3b044b 100644 --- a/03_source/frontend/src/routes/sections/dashboard.tsx +++ b/03_source/frontend/src/routes/sections/dashboard.tsx @@ -83,6 +83,10 @@ const PartyEventListPage = lazy(() => import('src/pages/dashboard/party-event/li const PartyEventCreatePage = lazy(() => import('src/pages/dashboard/party-event/new')); const PartyEventEditPage = lazy(() => import('src/pages/dashboard/party-event/edit')); +// PartyOrder +const PartyOrderListPage = lazy(() => import('src/pages/dashboard/party-order/list')); +const PartyOrderDetailsPage = lazy(() => import('src/pages/dashboard/party-order/details')); + // ---------------------------------------------------------------------- function SuspenseOutlet() { @@ -217,6 +221,14 @@ export const dashboardRoutes: RouteObject[] = [ { path: ':id/edit', element: }, ], }, + { + path: 'party-order', + children: [ + { index: true, element: }, + { path: 'list', element: }, + { path: ':id', element: }, + ], + }, ], }, ]; diff --git a/03_source/frontend/src/sections/order/view/order-list-view.tsx b/03_source/frontend/src/sections/order/view/order-list-view.tsx index 3985972..04b953f 100644 --- a/03_source/frontend/src/sections/order/view/order-list-view.tsx +++ b/03_source/frontend/src/sections/order/view/order-list-view.tsx @@ -13,7 +13,7 @@ import { useBoolean, useSetState } from 'minimal-shared/hooks'; import { varAlpha } from 'minimal-shared/utils'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { _orders, ORDER_STATUS_OPTIONS } from 'src/_mock'; +import { _party_orders, ORDER_STATUS_OPTIONS } from 'src/_mock'; import { deleteOrder, useGetOrders } from 'src/actions/order'; import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; import { ConfirmDialog } from 'src/components/custom-dialog'; diff --git a/03_source/frontend/src/sections/party-order/party-order-details-customer.tsx b/03_source/frontend/src/sections/party-order/party-order-details-customer.tsx new file mode 100644 index 0000000..0aff327 --- /dev/null +++ b/03_source/frontend/src/sections/party-order/party-order-details-customer.tsx @@ -0,0 +1,59 @@ +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import CardHeader from '@mui/material/CardHeader'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Iconify } from 'src/components/iconify'; +import type { IOrderCustomer } from 'src/types/party-order'; + +// ---------------------------------------------------------------------- + +type Props = { + customer?: IOrderCustomer; +}; + +export function OrderDetailsCustomer({ customer }: Props) { + return ( + <> + + + + } + /> + + + + + {customer?.name} + + {customer?.email} + +
+ IP address: + + {customer?.ipAddress} + +
+ + +
+
+ + ); +} diff --git a/03_source/frontend/src/sections/party-order/party-order-details-delivery.tsx b/03_source/frontend/src/sections/party-order/party-order-details-delivery.tsx new file mode 100644 index 0000000..a5fbcdb --- /dev/null +++ b/03_source/frontend/src/sections/party-order/party-order-details-delivery.tsx @@ -0,0 +1,57 @@ +import Box from '@mui/material/Box'; +import CardHeader from '@mui/material/CardHeader'; +import IconButton from '@mui/material/IconButton'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import { useTranslation } from 'react-i18next'; +import { Iconify } from 'src/components/iconify'; +import type { IOrderDelivery } from 'src/types/party-order'; + +// ---------------------------------------------------------------------- + +type Props = { + delivery?: IOrderDelivery; +}; + +export function OrderDetailsDelivery({ delivery }: Props) { + const { t } = useTranslation(); + return ( + <> + + + + } + /> + + + + {t('Ship by')} + + + {delivery?.shipBy} + + + + + {t('Speedy')} + + + {delivery?.speedy} + + + + + {t('Tracking No.')} + + + + {delivery?.trackingNumber} + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-order/party-order-details-history.tsx b/03_source/frontend/src/sections/party-order/party-order-details-history.tsx new file mode 100644 index 0000000..a283d9f --- /dev/null +++ b/03_source/frontend/src/sections/party-order/party-order-details-history.tsx @@ -0,0 +1,108 @@ +// src/sections/order/order-details-history.tsx +import Timeline from '@mui/lab/Timeline'; +import TimelineConnector from '@mui/lab/TimelineConnector'; +import TimelineContent from '@mui/lab/TimelineContent'; +import TimelineDot from '@mui/lab/TimelineDot'; +import TimelineItem, { timelineItemClasses } from '@mui/lab/TimelineItem'; +import TimelineSeparator from '@mui/lab/TimelineSeparator'; +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import CardHeader from '@mui/material/CardHeader'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; +import type { IOrderHistory } from 'src/types/party-order'; +import { fDateTime } from 'src/utils/format-time'; + +// ---------------------------------------------------------------------- + +type Props = { + history?: IOrderHistory; +}; + +export function OrderDetailsHistory({ history }: Props) { + const { t } = useTranslation(); + + const renderSummary = () => ( + +
+ {t('Order time')} + {fDateTime(history?.orderTime)} +
+ +
+ {t('Payment time')} + {fDateTime(history?.orderTime)} +
+ +
+ {t('Delivery time for the carrier')} + {fDateTime(history?.orderTime)} +
+ +
+ {t('Completion time')} + {fDateTime(history?.orderTime)} +
+
+ ); + + const renderTimeline = () => ( + + {history?.timeline.map((item, index) => { + const firstTime = index === 0; + const lastTime = index === history.timeline.length - 1; + + return ( + + + + {lastTime ? null : } + + + + {item.title} + + + {fDateTime(item.time)} + + + + ); + })} + + ); + + return ( + + + + {renderTimeline()} + {renderSummary()} + + + ); +} diff --git a/03_source/frontend/src/sections/party-order/party-order-details-items.tsx b/03_source/frontend/src/sections/party-order/party-order-details-items.tsx new file mode 100644 index 0000000..37cb283 --- /dev/null +++ b/03_source/frontend/src/sections/party-order/party-order-details-items.tsx @@ -0,0 +1,130 @@ +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import type { CardProps } from '@mui/material/Card'; +import Card from '@mui/material/Card'; +import CardHeader from '@mui/material/CardHeader'; +import IconButton from '@mui/material/IconButton'; +import ListItemText from '@mui/material/ListItemText'; +import { useTranslation } from 'react-i18next'; +import { Iconify } from 'src/components/iconify'; +import { Scrollbar } from 'src/components/scrollbar'; +import type { IOrderProductItem } from 'src/types/party-order'; +import { fCurrency } from 'src/utils/format-number'; + +// ---------------------------------------------------------------------- + +type Props = CardProps & { + taxes?: number; + shipping?: number; + discount?: number; + subtotal?: number; + totalAmount?: number; + items?: IOrderProductItem[]; +}; + +export function OrderDetailsItems({ + sx, + taxes, + shipping, + discount, + subtotal, + items = [], + totalAmount, + ...other +}: Props) { + const { t } = useTranslation(); + const renderTotal = () => ( + + + {t('Subtotal')} + {fCurrency(subtotal) || '-'} + + + + {t('Shipping')} + + {shipping ? `- ${fCurrency(shipping)}` : '-'} + + + + + {t('Discount')} + + {discount ? `- ${fCurrency(discount)}` : '-'} + + + + + {t('Taxes')} + + {taxes ? fCurrency(taxes) : '-'} + + + +
{t('Total')}
+ {fCurrency(totalAmount) || '-'} +
+
+ ); + + return ( + + + + + } + /> + + + {items.map((item) => ( + ({ + p: 3, + minWidth: 640, + display: 'flex', + alignItems: 'center', + borderBottom: `dashed 2px ${theme.vars.palette.background.neutral}`, + }), + ]} + > + + + + + x{item.quantity} + + + {fCurrency(item.price)} + + + ))} + + + {renderTotal()} + + ); +} diff --git a/03_source/frontend/src/sections/party-order/party-order-details-payment.tsx b/03_source/frontend/src/sections/party-order/party-order-details-payment.tsx new file mode 100644 index 0000000..9d9cd5a --- /dev/null +++ b/03_source/frontend/src/sections/party-order/party-order-details-payment.tsx @@ -0,0 +1,39 @@ +import Box from '@mui/material/Box'; +import CardHeader from '@mui/material/CardHeader'; +import IconButton from '@mui/material/IconButton'; +import { Iconify } from 'src/components/iconify'; +import type { IOrderPayment } from 'src/types/party-order'; + +// ---------------------------------------------------------------------- + +type Props = { + payment?: IOrderPayment; +}; + +export function OrderDetailsPayment({ payment }: Props) { + return ( + <> + + + + } + /> + + {payment?.cardNumber} + + + + ); +} diff --git a/03_source/frontend/src/sections/party-order/party-order-details-shipping.tsx b/03_source/frontend/src/sections/party-order/party-order-details-shipping.tsx new file mode 100644 index 0000000..5b78bc5 --- /dev/null +++ b/03_source/frontend/src/sections/party-order/party-order-details-shipping.tsx @@ -0,0 +1,44 @@ +import Box from '@mui/material/Box'; +import CardHeader from '@mui/material/CardHeader'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import { Iconify } from 'src/components/iconify'; +import type { IOrderShippingAddress } from 'src/types/party-order'; + +// ---------------------------------------------------------------------- + +type Props = { + shippingAddress?: IOrderShippingAddress; +}; + +export function OrderDetailsShipping({ shippingAddress }: Props) { + return ( + <> + + + + } + /> + + + + Address + + + {shippingAddress?.fullAddress} + + + + + Phone number + + + {shippingAddress?.phoneNumber} + + + + ); +} diff --git a/03_source/frontend/src/sections/party-order/party-order-details-toolbar.tsx b/03_source/frontend/src/sections/party-order/party-order-details-toolbar.tsx new file mode 100644 index 0000000..a898d65 --- /dev/null +++ b/03_source/frontend/src/sections/party-order/party-order-details-toolbar.tsx @@ -0,0 +1,138 @@ +// src/sections/order/order-details-toolbar.tsx + +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import MenuItem from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { usePopover } from 'minimal-shared/hooks'; +import { useTranslation } from 'react-i18next'; +import { CustomPopover } from 'src/components/custom-popover'; +import { Iconify } from 'src/components/iconify'; +import { Label } from 'src/components/label'; +import { RouterLink } from 'src/routes/components'; +import type { IDateValue } from 'src/types/common'; +import { fDateTime } from 'src/utils/format-time'; + +// ---------------------------------------------------------------------- + +type Props = { + status?: string; + backHref: string; + orderNumber?: string; + createdAt?: IDateValue; + onChangeStatus: (newValue: string) => void; + statusOptions: { value: string; label: string }[]; +}; + +export function OrderDetailsToolbar({ + status, + backHref, + createdAt, + orderNumber, + statusOptions, + onChangeStatus, +}: Props) { + const { t } = useTranslation(); + const menuActions = usePopover(); + + const renderMenuActions = () => ( + + + {statusOptions.map((option) => ( + { + menuActions.onClose(); + onChangeStatus(option.value); + }} + > + {t(option.label)} + + ))} + + + ); + + return ( + <> + + + + + + + + + Order {orderNumber} + + + + + {fDateTime(createdAt)} + + + + + + + + + + + + + + {renderMenuActions()} + + ); +} diff --git a/03_source/frontend/src/sections/party-order/party-order-table-filters-result.tsx b/03_source/frontend/src/sections/party-order/party-order-table-filters-result.tsx new file mode 100644 index 0000000..622ba92 --- /dev/null +++ b/03_source/frontend/src/sections/party-order/party-order-table-filters-result.tsx @@ -0,0 +1,66 @@ +import Chip from '@mui/material/Chip'; +import type { UseSetStateReturn } from 'minimal-shared/hooks'; +import { useCallback } from 'react'; +import type { FiltersResultProps } from 'src/components/filters-result'; +import { chipProps, FiltersBlock, FiltersResult } from 'src/components/filters-result'; +import type { IOrderTableFilters } from 'src/types/party-order'; +import { fDateRangeShortLabel } from 'src/utils/format-time'; + +// ---------------------------------------------------------------------- + +type Props = FiltersResultProps & { + onResetPage: () => void; + filters: UseSetStateReturn; +}; + +export function PartyOrderTableFiltersResult({ filters, totalResults, onResetPage, sx }: Props) { + const { state: currentFilters, setState: updateFilters, resetState: resetFilters } = filters; + + const handleRemoveKeyword = useCallback(() => { + onResetPage(); + updateFilters({ name: '' }); + }, [onResetPage, updateFilters]); + + const handleRemoveStatus = useCallback(() => { + onResetPage(); + updateFilters({ status: 'all' }); + }, [onResetPage, updateFilters]); + + const handleRemoveDate = useCallback(() => { + onResetPage(); + updateFilters({ startDate: null, endDate: null }); + }, [onResetPage, updateFilters]); + + const handleReset = useCallback(() => { + onResetPage(); + resetFilters(); + }, [onResetPage, resetFilters]); + + return ( + + + + + + + + + + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-order/party-order-table-row.tsx b/03_source/frontend/src/sections/party-order/party-order-table-row.tsx new file mode 100644 index 0000000..b8e190e --- /dev/null +++ b/03_source/frontend/src/sections/party-order/party-order-table-row.tsx @@ -0,0 +1,237 @@ +// src/sections/order/view/order-list-view.tsx +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import Collapse from '@mui/material/Collapse'; +import IconButton from '@mui/material/IconButton'; +import Link from '@mui/material/Link'; +import ListItemText from '@mui/material/ListItemText'; +import MenuItem from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import TableCell from '@mui/material/TableCell'; +import TableRow from '@mui/material/TableRow'; +import { useBoolean, usePopover } from 'minimal-shared/hooks'; +import { useTranslation } from 'react-i18next'; +import { ConfirmDialog } from 'src/components/custom-dialog'; +import { CustomPopover } from 'src/components/custom-popover'; +import { Iconify } from 'src/components/iconify'; +import { Label } from 'src/components/label'; +import { RouterLink } from 'src/routes/components'; +import type { IOrderItem } from 'src/types/party-order'; +import { fCurrency } from 'src/utils/format-number'; +import { fDate, fTime } from 'src/utils/format-time'; + +// ---------------------------------------------------------------------- + +type Props = { + row: IOrderItem; + selected: boolean; + detailsHref: string; + onSelectRow: () => void; + onDeleteRow: () => void; +}; + +export function PartyOrderTableRow({ + row, + selected, + onSelectRow, + onDeleteRow, + detailsHref, +}: Props) { + const confirmDialog = useBoolean(); + const menuActions = usePopover(); + const collapseRow = useBoolean(); + const { t } = useTranslation(); + + const renderPrimaryRow = () => ( + + + + + + + + {row.orderNumber} + + + + + + + + + {row.customer.name} + + + {row.customer.email} + + + + + + + + + + {row.totalQuantity} + + {fCurrency(row.subtotal)} + + + + + + + + + + + + + + + + ); + + const renderSecondaryRow = () => ( + + + + + {row.items.map((item) => ( + ({ + display: 'flex', + alignItems: 'center', + p: theme.spacing(1.5, 2, 1.5, 1.5), + '&:not(:last-of-type)': { + borderBottom: `solid 2px ${theme.vars.palette.background.neutral}`, + }, + })} + > + + + + +
x{item.quantity}
+ + {fCurrency(item.price)} +
+ ))} +
+
+
+
+ ); + + const renderMenuActions = () => ( + + + { + confirmDialog.onTrue(); + menuActions.onClose(); + }} + sx={{ color: 'error.main' }} + > + + {t('Delete')} + + +
  • + menuActions.onClose()}> + + {t('View')} + +
  • +
    +
    + ); + + const renderConfrimDialog = () => ( + + Delete + + } + /> + ); + + return ( + <> + {renderPrimaryRow()} + {renderSecondaryRow()} + {renderMenuActions()} + {renderConfrimDialog()} + + ); +} diff --git a/03_source/frontend/src/sections/party-order/party-order-table-toolbar.tsx b/03_source/frontend/src/sections/party-order/party-order-table-toolbar.tsx new file mode 100644 index 0000000..22d3ee6 --- /dev/null +++ b/03_source/frontend/src/sections/party-order/party-order-table-toolbar.tsx @@ -0,0 +1,156 @@ +import Box from '@mui/material/Box'; +import { formHelperTextClasses } from '@mui/material/FormHelperText'; +import IconButton from '@mui/material/IconButton'; +import InputAdornment from '@mui/material/InputAdornment'; +import MenuItem from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import TextField from '@mui/material/TextField'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import type { UseSetStateReturn } from 'minimal-shared/hooks'; +import { usePopover } from 'minimal-shared/hooks'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CustomPopover } from 'src/components/custom-popover'; +import { Iconify } from 'src/components/iconify'; +import type { IDatePickerControl } from 'src/types/common'; +import type { IOrderTableFilters } from 'src/types/party-order'; + +// ---------------------------------------------------------------------- + +type Props = { + dateError: boolean; + onResetPage: () => void; + filters: UseSetStateReturn; +}; + +export function PartyOrderTableToolbar({ filters, onResetPage, dateError }: Props) { + const { t } = useTranslation(); + const menuActions = usePopover(); + + const { state: currentFilters, setState: updateFilters } = filters; + + const handleFilterName = useCallback( + (event: React.ChangeEvent) => { + onResetPage(); + updateFilters({ name: event.target.value }); + }, + [onResetPage, updateFilters] + ); + + const handleFilterStartDate = useCallback( + (newValue: IDatePickerControl) => { + onResetPage(); + updateFilters({ startDate: newValue }); + }, + [onResetPage, updateFilters] + ); + + const handleFilterEndDate = useCallback( + (newValue: IDatePickerControl) => { + onResetPage(); + updateFilters({ endDate: newValue }); + }, + [onResetPage, updateFilters] + ); + + const renderMenuActions = () => ( + + + menuActions.onClose()}> + + {t('Print')} + + + menuActions.onClose()}> + + {t('Import')} + + + menuActions.onClose()}> + + {t('Export')} + + + + ); + + return ( + <> + + + + + + + + + + ), + }, + }} + /> + + + + + + + + {renderMenuActions()} + + ); +} diff --git a/03_source/frontend/src/sections/party-order/view/index.ts b/03_source/frontend/src/sections/party-order/view/index.ts new file mode 100644 index 0000000..c9cfd64 --- /dev/null +++ b/03_source/frontend/src/sections/party-order/view/index.ts @@ -0,0 +1,3 @@ +export * from './party-order-list-view'; + +export * from './party-order-details-view'; diff --git a/03_source/frontend/src/sections/party-order/view/party-order-details-view.tsx b/03_source/frontend/src/sections/party-order/view/party-order-details-view.tsx new file mode 100644 index 0000000..7ac3d71 --- /dev/null +++ b/03_source/frontend/src/sections/party-order/view/party-order-details-view.tsx @@ -0,0 +1,104 @@ +// src/sections/order/view/order-details-view.tsx + +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Divider from '@mui/material/Divider'; +import Grid from '@mui/material/Grid'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { ORDER_STATUS_OPTIONS } from 'src/_mock'; +import { changeStatus } from 'src/actions/party-order'; +import { DashboardContent } from 'src/layouts/dashboard'; +import { useTranslate } from 'src/locales'; +import { paths } from 'src/routes/paths'; +import type { IOrderItem } from 'src/types/party-order'; +import { OrderDetailsCustomer } from '../party-order-details-customer'; +import { OrderDetailsDelivery } from '../party-order-details-delivery'; +import { OrderDetailsHistory } from '../party-order-details-history'; +import { OrderDetailsItems } from '../party-order-details-items'; +import { OrderDetailsPayment } from '../party-order-details-payment'; +import { OrderDetailsShipping } from '../party-order-details-shipping'; +import { OrderDetailsToolbar } from '../party-order-details-toolbar'; + +// ---------------------------------------------------------------------- + +type Props = { + order: IOrderItem; +}; + +export function OrderDetailsView({ order }: Props) { + const { t } = useTranslation(); + + const [status, setStatus] = useState(order.status); + + const handleChangeStatus = useCallback( + async (newValue: string) => { + setStatus(newValue); + // change order status + try { + if (order?.id) { + await changeStatus(order.id, newValue); + + toast.success('order status updated'); + } + } catch (error) { + console.error(error); + toast.warning('error during update order status'); + } + }, + [order.id] + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-order/view/party-order-list-view.tsx b/03_source/frontend/src/sections/party-order/view/party-order-list-view.tsx new file mode 100644 index 0000000..79d0078 --- /dev/null +++ b/03_source/frontend/src/sections/party-order/view/party-order-list-view.tsx @@ -0,0 +1,364 @@ +// src/sections/order/view/order-list-view.tsx + +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import IconButton from '@mui/material/IconButton'; +import Tab from '@mui/material/Tab'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import Tabs from '@mui/material/Tabs'; +import Tooltip from '@mui/material/Tooltip'; +import { useBoolean, useSetState } from 'minimal-shared/hooks'; +import { varAlpha } from 'minimal-shared/utils'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { _party_orders, PARTY_ORDER_STATUS_OPTIONS } from 'src/_mock'; +import { deletePartyOrder, useGetPartyOrders } from 'src/actions/party-order'; +import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; +import { ConfirmDialog } from 'src/components/custom-dialog'; +import { Iconify } from 'src/components/iconify'; +import { Label } from 'src/components/label'; +import { Scrollbar } from 'src/components/scrollbar'; +import { toast } from 'src/components/snackbar'; +import type { TableHeadCellProps } from 'src/components/table'; +import { + emptyRows, + getComparator, + rowInPage, + TableEmptyRows, + TableHeadCustom, + TableNoData, + TablePaginationCustom, + TableSelectedAction, + useTable, +} from 'src/components/table'; +import { DashboardContent } from 'src/layouts/dashboard'; +import { useRouter } from 'src/routes/hooks'; +import { paths } from 'src/routes/paths'; +import type { IOrderItem, IOrderTableFilters } from 'src/types/party-order'; +import { fIsAfter, fIsBetween } from 'src/utils/format-time'; +import { PartyOrderTableFiltersResult } from '../party-order-table-filters-result'; +import { PartyOrderTableRow } from '../party-order-table-row'; +import { PartyOrderTableToolbar } from '../party-order-table-toolbar'; + +// ---------------------------------------------------------------------- + +const STATUS_OPTIONS = [{ value: 'all', label: 'All' }, ...PARTY_ORDER_STATUS_OPTIONS]; + +// ---------------------------------------------------------------------- + +export function PartyOrderListView() { + const { t } = useTranslation(); + const router = useRouter(); + + const TABLE_HEAD: TableHeadCellProps[] = [ + { id: 'orderNumber', label: t('Order'), width: 88 }, + { id: 'name', label: t('Customer') }, + { id: 'createdAt', label: t('Date'), width: 140 }, + { id: 'totalQuantity', label: t('Items'), width: 120, align: 'center' }, + { id: 'totalAmount', label: t('Price'), width: 140 }, + { id: 'status', label: t('Status'), width: 110 }, + { id: '', width: 88 }, + ]; + + const { orders, mutate, ordersLoading } = useGetPartyOrders(); + + const table = useTable({ defaultOrderBy: 'orderNumber' }); + + const confirmDialog = useBoolean(); + + const [tableData, setTableData] = useState([]); + + useEffect(() => { + setTableData(orders); + }, [orders]); + + const filters = useSetState({ + name: '', + status: 'all', + startDate: null, + endDate: null, + }); + const { state: currentFilters, setState: updateFilters } = filters; + + const dateError = fIsAfter(currentFilters.startDate, currentFilters.endDate); + + const dataFiltered = applyFilter({ + inputData: tableData, + comparator: getComparator(table.order, table.orderBy), + filters: currentFilters, + dateError, + }); + + const dataInPage = rowInPage(dataFiltered, table.page, table.rowsPerPage); + + const canReset = + !!currentFilters.name || + currentFilters.status !== 'all' || + (!!currentFilters.startDate && !!currentFilters.endDate); + + const notFound = (!dataFiltered.length && canReset) || !dataFiltered.length; + + const handleDeleteRow = useCallback( + async (id: string) => { + // const deleteRow = tableData.filter((row) => row.id !== id); + + try { + await deletePartyOrder(id); + toast.success('Delete success!'); + mutate(); + } catch (error) { + console.error(error); + toast.error('Delete failed!'); + } + + // toast.success('Delete success!'); + // setTableData(deleteRow); + // table.onUpdatePageDeleteRow(dataInPage.length); + }, + [table, tableData, mutate] + ); + + const handleDeleteRows = useCallback(() => { + const deleteRows = tableData.filter((row) => !table.selected.includes(row.id)); + + toast.success('Delete success!'); + + setTableData(deleteRows); + + table.onUpdatePageDeleteRows(dataInPage.length, dataFiltered.length); + }, [dataFiltered.length, dataInPage.length, table, tableData]); + + const handleFilterStatus = useCallback( + (event: React.SyntheticEvent, newValue: string) => { + table.onResetPage(); + updateFilters({ status: newValue }); + }, + [updateFilters, table] + ); + + const renderConfirmDialog = () => ( + + Are you sure want to delete {table.selected.length} items? + + } + action={ + + } + /> + ); + + useEffect(() => { + mutate(); + }, []); + + if (!orders) return <>loading; + if (ordersLoading) return <>loading; + + return ( + <> + + + + + ({ + px: 2.5, + boxShadow: `inset 0 -2px 0 0 ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`, + }), + ]} + > + {STATUS_OPTIONS.map((tab) => ( + + {['completed', 'pending', 'cancelled', 'refunded'].includes(tab.value) + ? tableData.filter((user) => user.status === tab.value).length + : tableData.length} + + } + /> + ))} + + + + + {canReset && ( + + )} + + + + table.onSelectAllRows( + checked, + dataFiltered.map((row) => row.id) + ) + } + action={ + + + + + + } + /> + + + + + table.onSelectAllRows( + checked, + dataFiltered.map((row) => row.id) + ) + } + /> + + + {dataFiltered + .slice( + table.page * table.rowsPerPage, + table.page * table.rowsPerPage + table.rowsPerPage + ) + .map((row) => ( + table.onSelectRow(row.id)} + onDeleteRow={() => handleDeleteRow(row.id)} + detailsHref={paths.dashboard.order.details(row.id)} + /> + ))} + + + + + +
    +
    +
    + + +
    +
    + + {renderConfirmDialog()} + + ); +} + +// ---------------------------------------------------------------------- + +type ApplyFilterProps = { + dateError: boolean; + inputData: IOrderItem[]; + filters: IOrderTableFilters; + comparator: (a: any, b: any) => number; +}; + +function applyFilter({ inputData, comparator, filters, dateError }: ApplyFilterProps) { + const { status, name, startDate, endDate } = filters; + + const stabilizedThis = inputData.map((el, index) => [el, index] as const); + + stabilizedThis.sort((a, b) => { + const order = comparator(a[0], b[0]); + if (order !== 0) return order; + return a[1] - b[1]; + }); + + inputData = stabilizedThis.map((el) => el[0]); + + if (name) { + inputData = inputData.filter(({ orderNumber, customer }) => + [orderNumber, customer.name, customer.email].some((field) => + field?.toLowerCase().includes(name.toLowerCase()) + ) + ); + } + + if (status !== 'all') { + inputData = inputData.filter((order) => order.status === status); + } + + if (!dateError) { + if (startDate && endDate) { + inputData = inputData.filter((order) => fIsBetween(order.createdAt, startDate, endDate)); + } + } + + return inputData; +} diff --git a/03_source/frontend/src/types/party-order.ts b/03_source/frontend/src/types/party-order.ts new file mode 100644 index 0000000..943e5f3 --- /dev/null +++ b/03_source/frontend/src/types/party-order.ts @@ -0,0 +1,71 @@ +import type { IDatePickerControl, IDateValue } from './common'; + +// ---------------------------------------------------------------------- + +export type IOrderTableFilters = { + name: string; + status: string; + endDate: IDatePickerControl; + startDate: IDatePickerControl; +}; + +export type IOrderHistory = { + orderTime: IDateValue; + paymentTime: IDateValue; + deliveryTime: IDateValue; + completionTime: IDateValue; + timeline: { title: string; time: IDateValue }[]; +}; + +export type IOrderShippingAddress = { + fullAddress: string; + phoneNumber: string; +}; + +export type IOrderPayment = { + cardType: string; + cardNumber: string; +}; + +export type IOrderDelivery = { + shipBy: string; + speedy: string; + trackingNumber: string; +}; + +export type IOrderCustomer = { + id: string; + name: string; + email: string; + avatarUrl: string; + ipAddress: string; +}; + +export type IOrderProductItem = { + id: string; + sku: string; + name: string; + price: number; + coverUrl: string; + quantity: number; +}; + +export type IOrderItem = { + id: string; + createdAt: IDateValue; + // + taxes: number; + status: string; + shipping: number; + discount: number; + subtotal: number; + orderNumber: string; + totalAmount: number; + totalQuantity: number; + history: IOrderHistory | undefined; + payment: IOrderPayment; + customer: IOrderCustomer; + delivery: IOrderDelivery; + items: IOrderProductItem[]; + shippingAddress: IOrderShippingAddress; +};