From 3f36e10c80ccc2d82b799451618f98314def5922 Mon Sep 17 00:00:00 2001 From: "Brian.Jiang2021" Date: Mon, 6 Mar 2023 17:03:05 +0800 Subject: [PATCH] feat: bulk upload csv https://bigc-b2b.atlassian.net/browse/BUN-650 --- .../components/table/B3PaginationTable.tsx | 7 +- .../src/components/table/B3Table.tsx | 8 +- .../src/components/upload/B3Upload.tsx | 276 ++++++++++++++-- .../src/components/upload/BulkUploadTable.tsx | 303 ++++++++++++++++++ .../components/upload/BulkUploadTableCard.tsx | 103 ++++++ .../src/pages/dashboard/Dashboard.tsx | 11 +- .../quickorder/components/QuickOrderPad.tsx | 141 +++++++- .../components/AddToShoppingList.tsx | 114 ++++++- .../src/shared/service/b2b/graphql/product.ts | 26 ++ .../src/shared/service/b2b/index.ts | 4 + 10 files changed, 967 insertions(+), 26 deletions(-) create mode 100644 apps/storefront/src/components/upload/BulkUploadTable.tsx create mode 100644 apps/storefront/src/components/upload/BulkUploadTableCard.tsx diff --git a/apps/storefront/src/components/table/B3PaginationTable.tsx b/apps/storefront/src/components/table/B3PaginationTable.tsx index 41fa9ed2..949c0b02 100644 --- a/apps/storefront/src/components/table/B3PaginationTable.tsx +++ b/apps/storefront/src/components/table/B3PaginationTable.tsx @@ -45,7 +45,7 @@ interface B3PaginationTableProps { noDataText?: string, tableKey?: string, getRequestList: any, - searchParams: T, + searchParams?: T, requestLoading?: (bool: boolean) => void, showCheckbox?: boolean, selectedSymbol?: string, @@ -55,6 +55,7 @@ interface B3PaginationTableProps { labelRowsPerPage?: string, itemIsMobileSpacing?: number, disableCheckbox?: boolean, + showRowsPerPageOptions?: boolean, } const PaginationTable:(props: B3PaginationTableProps) => ReactElement = ({ @@ -83,6 +84,8 @@ const PaginationTable:(props: B3PaginationTableProps) => ReactElement = ({ labelRowsPerPage = '', itemIsMobileSpacing = 2, disableCheckbox = false, + showPagination = true, + showRowsPerPageOptions = true, }, ref?: Ref) => { const initPagination = { offset: 0, @@ -221,6 +224,8 @@ const PaginationTable:(props: B3PaginationTableProps) => ReactElement = ({ handleSelectOneItem={handleSelectOneItem} showBorder={showBorder} labelRowsPerPage={labelRowsPerPage} + showPagination={showPagination} + showRowsPerPageOptions={showRowsPerPageOptions} /> ) } diff --git a/apps/storefront/src/components/table/B3Table.tsx b/apps/storefront/src/components/table/B3Table.tsx index 2199ac5c..f472202c 100644 --- a/apps/storefront/src/components/table/B3Table.tsx +++ b/apps/storefront/src/components/table/B3Table.tsx @@ -67,6 +67,7 @@ interface TableProps { selectCheckbox?: Array, labelRowsPerPage?: string, disableCheckbox?: boolean, + showRowsPerPageOptions?: boolean, } export const B3Table:(props: TableProps) => ReactElement = ({ @@ -101,6 +102,7 @@ export const B3Table:(props: TableProps) => ReactElement = ({ selectCheckbox = [], labelRowsPerPage = '', disableCheckbox = false, + showRowsPerPageOptions = true, }) => { const { offset, @@ -187,7 +189,7 @@ export const B3Table:(props: TableProps) => ReactElement = ({ { showPagination && ( (props: TableProps) => ReactElement = ({ { showPagination && ( (props: TableProps) => ReactElement = ({ { showPagination && ( >, + bulkUploadTitle?: string, + addBtnText?: string, + handleAddToList: (validProduct: CustomFieldItems) => void, + setProductData?: (product: CustomFieldItems) => void, + isLoading?: boolean, +} + +interface BulkUploadCSVProps { + currencyCode: string, + productList: CustomFieldItems, + channelId?: number, +} + const FileUploadContainer = styled(Box)(() => ({ width: '100%', border: '1px dashed #1976D2', @@ -43,15 +87,87 @@ const FileUploadContainer = styled(Box)(() => ({ }, })) -export const B3Upload = () => { +export const B3Upload = (props: B3UploadProps) => { + const { + isOpen, + setIsOpen, + bulkUploadTitle = 'Bulk upload', + addBtnText = 'add to list', + handleAddToList = () => {}, + setProductData = () => {}, + isLoading = false, + } = props + + const [isMobile] = useMobile() + + const { + state: { + isB2BUser, + currentChannelId, + }, + } = useContext(GlobaledContext) const uploadRef = useRef(null) const [step, setStep] = useState('init') + const [fileDatas, setFileDatas] = useState({}) + const [fileName, setFileName] = useState('') + + const { + currency_code: currencyCode, + } = getDefaultCurrencyInfo() + + const handleVerificationFile = (size: number, type: string) => { + if (type !== 'text/csv') { + snackbar.error('Table structure is wrong. Please download sample and follow it\'s structure.') + return false + } + + if (size > 1024 * 1024 * 50) { + snackbar.error('Maximum file size 50MB') + return false + } + + return true + } + + const handleBulkUploadCSV = async (parseData: CustomFieldItems) => { + try { + const params: BulkUploadCSVProps = { + currencyCode, + productList: parseData, + } + + if (!isB2BUser) params.channelId = currentChannelId + const BulkUploadCSV = isB2BUser ? B2BProductsBulkUploadCSV : BcProductsBulkUploadCSV + + const { + productUpload, + } = await BulkUploadCSV(params) + + if (productUpload) { + const { + result, + } = productUpload + const validProduct = result?.validProduct || [] + + setProductData(validProduct) + setFileDatas(result) + setStep('end') + } + } catch (e) { + setStep('init') + console.error(e) + } + } const handleChange = async (files: File[]) => { // init loadding end const file = files.length > 0 ? files[0] : null if (file) { + setFileName(file.name) + const isPass = handleVerificationFile(file?.size, file?.type) + + if (!isPass) return const reader = new FileReader() reader.addEventListener('load', async (b: any) => { @@ -62,15 +178,13 @@ export const B3Upload = () => { const content = csvdata.split('\n') const EmptyData = removeEmptyRow(content) const parseData = parseEmptyData(EmptyData) - console.log(EmptyData, parseData) // DOTO: - await new Promise((r) => { + await new Promise(() => { setTimeout(() => { - r('1') - }, 5000) + handleBulkUploadCSV(parseData) + }, 1000) }) - setStep('end') } }) @@ -79,7 +193,108 @@ export const B3Upload = () => { } const openFile = () => { - if (uploadRef.current) (uploadRef.current.children[1] as any).click() + if (uploadRef.current) (uploadRef.current.children[1] as HTMLElement).click() + } + + const getValidProducts = (products: CustomFieldItems) => { + const notPurchaseSku: string[] = [] + const productItems: CustomFieldItems[] = [] + const limitProduct: CustomFieldItems[] = [] + const minLimitQuantity: CustomFieldItems[] = [] + const maxLimitQuantity: CustomFieldItems[] = [] + const outOfStock: CustomFieldItems[] = [] + + products.forEach((item: CustomFieldItems) => { + const { + products: currentProduct, + qty, + } = item + const { + option, + isStock, + stock, + purchasingDisabled, + maxQuantity, + minQuantity, + variantSku, + variantId, + productId, + } = currentProduct + + if (purchasingDisabled === '1') { + notPurchaseSku.push(variantSku) + return + } + + if (isStock === '1' && stock === 0) { + outOfStock.push(variantSku) + return + } + + if ((isStock === '1' && stock > 0) && stock < +qty) { + limitProduct.push({ + variantSku, + AvailableAmount: stock, + }) + return + } + + if (+minQuantity > 0 && +qty < +minQuantity) { + minLimitQuantity.push({ + variantSku, + minQuantity, + }) + + return + } + + if (+maxQuantity > 0 && +qty > +maxQuantity) { + maxLimitQuantity.push({ + variantSku, + maxQuantity, + }) + + return + } + + const optionsList = option.map((item: CustomFieldItems) => ({ + optionId: item.option_id, + optionValue: item.id, + })) + + productItems.push({ + productId: parseInt(productId, 10) || 0, + variantId: parseInt(variantId, 10) || 0, + quantity: +qty, + optionList: optionsList, + }) + }) + + return { + notPurchaseSku, + productItems, + limitProduct, + minLimitQuantity, + maxLimitQuantity, + outOfStock, + } + } + + const handleConfirmToList = () => { + const validProduct = fileDatas?.validProduct || [] + const stockErrorFile = fileDatas?.stockErrorFile || '' + const stockErrorSkus = fileDatas?.stockErrorSkus || [] + if (validProduct?.length === 0) return + + if (validProduct) { + const productsData: CustomFieldItems = getValidProducts(validProduct) + + if (stockErrorSkus.length > 0) { + productsData.stockErrorFile = stockErrorFile + } + + handleAddToList(productsData) + } } const content = ( @@ -103,7 +318,7 @@ export const B3Upload = () => { justifyContent="center" xs={12} > - + { sx={{ fontWeight: 400, fontSize: '14px', + display: 'flex', + flexWrap: isMobile ? 'wrap' : 'nowrap', + justifyContent: 'center', }} > { color: '#1976D2', cursor: 'pointer', whiteSpace: 'nowrap', + marginLeft: '0.5rem', }} > - Download sample + + Download sample + @@ -166,16 +387,24 @@ export const B3Upload = () => { ) return ( - { + handleConfirmToList() + } : () => { + setIsOpen(false) + }} + showRightBtn={false} + isShowBordered={false} > @@ -199,10 +428,21 @@ export const B3Upload = () => { { step === 'loadding' && } + + { + step === 'end' && ( + + ) + } - { - step === 'end' && 123123 - } + ) diff --git a/apps/storefront/src/components/upload/BulkUploadTable.tsx b/apps/storefront/src/components/upload/BulkUploadTable.tsx new file mode 100644 index 00000000..a989f834 --- /dev/null +++ b/apps/storefront/src/components/upload/BulkUploadTable.tsx @@ -0,0 +1,303 @@ +import { + useState, + MouseEvent, +} from 'react' + +import { + Box, + Button, + Typography, + MenuItem, + Menu, + Tabs, + Tab, + Link, +} from '@mui/material' +import { + InsertDriveFile, + MoreHoriz, +} from '@mui/icons-material' + +import { + styled, +} from '@mui/material/styles' + +import { + TableColumnItem, +} from '@/components/table/B3Table' + +import { + B3PaginationTable, +} from '@/components/table/B3PaginationTable' + +import { + useMobile, +} from '@/hooks' + +import BulkUploadTableCard from './BulkUploadTableCard' + +interface BulkUploadTableProps { + setStep: (step: string) => void, + fileDatas: CustomFieldItems | null, + fileName: string, +} + +interface ListItem { + [key: string]: string +} + +const StyledTableContainer = styled(Box)(() => { + const [isMobile] = useMobile() + const style = { + boxShadow: 'none', + } + + const mobileStyle = { + boxShadow: '0px 2px 1px -1px rgba(0, 0, 0, 0.2), 0px 1px 1px rgba(0, 0, 0, 0.14), 0px 1px 3px rgba(0, 0, 0, 0.12)', + borderRadius: '4px', + padding: '16px', + marginTop: '1rem', + } + return ({ + '& .CSVProducts-info': isMobile ? mobileStyle : style, + }) +}) + +const BulkUploadTable = (props: BulkUploadTableProps) => { + const { + setStep, + fileDatas, + fileName, + } = props + const [isMobile] = useMobile() + + const columnErrorsItems: TableColumnItem [] = [ + { + key: 'sku', + title: 'SKU', + width: '25%', + render: (row) => {row.sku}, + }, + { + key: 'qty', + title: 'Qty', + width: '20%', + render: (row) => {row.qty}, + }, + { + key: 'row', + title: 'Row', + width: '20%', + render: (row) => {row.row}, + }, + { + key: 'error', + title: 'Error', + width: '35%', + render: (row) => {row.error}, + }, + ] + + const columnValidItems: TableColumnItem [] = [ + { + key: 'sku', + title: 'SKU', + width: '50%', + render: (row) => {row.sku}, + }, + { + key: 'qty', + title: 'Qty', + width: '50%', + render: (row) => {row.qty}, + }, + ] + + const errorProduct = fileDatas?.errorProduct || [] + const validProduct = fileDatas?.validProduct || [] + const [anchorEl, setAnchorEl] = useState(null) + const [open, setOpen] = useState(Boolean(anchorEl)) + const [activeTab, setActiveTab] = useState(errorProduct.length > 0 ? 'error' : 'valid') + + const handleOpenBtnList = (e: MouseEvent) => { + setAnchorEl(e.currentTarget) + setOpen(true) + } + + const handleClose = () => { + setAnchorEl(null) + setOpen(false) + } + + const handleRemoveCsv = () => { + handleClose() + setStep('init') + } + + const handleChangeTab = (e: CustomFieldItems, selectedTabValue: any) => { + setActiveTab(selectedTabValue) + } + + const getProductInfo = (params: CustomFieldItems) => { + if (activeTab === 'error') { + return { + edges: errorProduct, + totalCount: errorProduct.length || 0, + } + } + + if (activeTab === 'valid') { + const { + first, + offset, + } = params + + const start = offset + const limit = first + start + const currentPageProduct = validProduct.slice(start, limit) + + return { + edges: currentPageProduct, + totalCount: validProduct.length || 0, + } + } + + return { + edges: [], + totalCount: 0, + } + } + + return ( + + + + + + {fileName} + + + + + + + { + handleRemoveCsv() + }} + sx={{ + color: '#D32F2F', + }} + > + Remove + + + + + + + + { + errorProduct.length > 0 && ( + + ) + } + { + validProduct.length > 0 && ( + + ) + } + + + + + ( + + )} + /> + + + { + activeTab === 'error' && ( + + Download errors + + ) + } + + + ) +} + +export default BulkUploadTable diff --git a/apps/storefront/src/components/upload/BulkUploadTableCard.tsx b/apps/storefront/src/components/upload/BulkUploadTableCard.tsx new file mode 100644 index 00000000..4418c648 --- /dev/null +++ b/apps/storefront/src/components/upload/BulkUploadTableCard.tsx @@ -0,0 +1,103 @@ +import { + Box, + Typography, +} from '@mui/material' + +interface BulkUploadTableCardProps { + products: CustomFieldItems, + activeTab: string, +} + +const BulkUploadTableCard = (props: BulkUploadTableCardProps) => { + const { + products, + activeTab, + } = props + + console.log(products, 'BulkUploadTableCard') + const lineItemStyle = { + display: 'flex', + } + + return ( + + + + SKU: + {' '} + + {products.sku} + + + + Qty: + {' '} + + {products.qty} + + + { + activeTab === 'error' && ( + <> + + + Row: + {' '} + + {products.row + 2} + + + + Error: + {' '} + + {products.error} + + + ) + } + + ) +} + +export default BulkUploadTableCard diff --git a/apps/storefront/src/pages/dashboard/Dashboard.tsx b/apps/storefront/src/pages/dashboard/Dashboard.tsx index f8c3693d..b0e46bf7 100644 --- a/apps/storefront/src/pages/dashboard/Dashboard.tsx +++ b/apps/storefront/src/pages/dashboard/Dashboard.tsx @@ -11,6 +11,9 @@ import { MenuItem, IconButton, } from '@mui/material' +import { + styled, +} from '@mui/material/styles' import MoreHorizIcon from '@mui/icons-material/MoreHoriz' import { @@ -53,6 +56,10 @@ interface ListItem { [key: string]: string } +const StyledMenuItem = styled(MenuItem)(() => ({ + boxShadow: '0px 2px 5px -1px rgb(0 0 0, 0.2), 0px 3px 13px 0px rgb(0, 0, 0, 0.14), 0px 1px 4px 0px rgb(0, 0, 0, 0.12)', +})) + const Dashboard = () => { const { state: { @@ -247,14 +254,14 @@ const Dashboard = () => { 'aria-labelledby': 'basic-button', }} > - startActing()} > Masquerade - + ) diff --git a/apps/storefront/src/pages/quickorder/components/QuickOrderPad.tsx b/apps/storefront/src/pages/quickorder/components/QuickOrderPad.tsx index a186c73d..df583314 100644 --- a/apps/storefront/src/pages/quickorder/components/QuickOrderPad.tsx +++ b/apps/storefront/src/pages/quickorder/components/QuickOrderPad.tsx @@ -1,3 +1,8 @@ +import { + useEffect, + useState, +} from 'react' + import { Box, Divider, @@ -5,6 +10,7 @@ import { Button, Card, CardContent, + Link, } from '@mui/material' import UploadFileIcon from '@mui/icons-material/UploadFile' @@ -25,6 +31,7 @@ import { import { B3LinkTipContent, + B3Upload, } from '@/components' interface successTipOptions{ @@ -44,6 +51,11 @@ const successTip = (options: successTipOptions) => () => ( ) export const QuickOrderPad = () => { + const [isOpenBulkLoadCSV, setIsOpenBulkLoadCSV] = useState(false) + const [productData, setProductData] = useState([]) + const [addBtnText, setAddBtnText] = useState('Add to cart') + const [isLoading, setIsLoading] = useState(false) + const handleSplitOptionId = (id: string | number) => { if (typeof id === 'string' && id.includes('attribute')) { const idRight = id.split('[')[1] @@ -104,6 +116,119 @@ export const QuickOrderPad = () => { return res } + const limitProductTips = (data: CustomFieldItems) => ( + <> +

+ {`SKU ${data.variantSku} is not enough stock`} +

+

+ {`Available amount - ${data.AvailableAmount}.`} +

+ + ) + + const outOfStockProductTips = (outOfStock: CustomFieldItems, fileErrorsCSV: string) => ( + <> +

+ {`SKU ${outOfStock} are out of stock.`} +

+ + Download errors csv + + + ) + + const handleAddToCart = async (validProduct: CustomFieldItems) => { + setIsLoading(true) + try { + const { + notPurchaseSku, + productItems, + limitProduct, + minLimitQuantity, + maxLimitQuantity, + outOfStock, + stockErrorFile, + } = validProduct + + if (productItems.length > 0) { + const cartInfo = await getCartInfo() + const res = cartInfo.length ? await addProductToCart({ + lineItems: productItems, + }, cartInfo[0].id) : await createCart({ + lineItems: productItems, + }) + if (res.status) { + snackbar.error(res.detail) + } else if (!res.status) { + snackbar.success('', { + jsx: successTip({ + message: 'Products were added to cart', + link: '/cart.php', + linkText: 'VIEW CART', + isOutLink: true, + }), + isClose: true, + }) + } + } + + if (limitProduct.length > 0) { + limitProduct.forEach((data: CustomFieldItems) => { + snackbar.warning('', { + jsx: () => limitProductTips(data), + }) + }) + } + + if (notPurchaseSku.length > 0) { + snackbar.error(`SKU ${notPurchaseSku} cannot be purchased in online store.`) + } + + if (outOfStock.length > 0 && stockErrorFile) { + snackbar.error('', { + jsx: () => outOfStockProductTips(outOfStock, stockErrorFile), + }) + } + + if (minLimitQuantity.length > 0) { + minLimitQuantity.forEach((data: CustomFieldItems) => { + snackbar.error(`You need to purchase a minimum of ${data.minQuantity} of the ${data.variantSku} per order.`) + }) + } + + if (maxLimitQuantity.length > 0) { + maxLimitQuantity.forEach((data: CustomFieldItems) => { + snackbar.error(`You need to purchase a minimum of ${data.maxQuantity} of the ${data.variantSku} per order.`) + }) + } + + setIsOpenBulkLoadCSV(false) + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + if (productData?.length > 0) { + setAddBtnText(`Add ${productData.length} products to cart`) + } + }, [productData]) + return ( { margin: '20px 0 0', }} > - + + diff --git a/apps/storefront/src/shared/service/b2b/graphql/product.ts b/apps/storefront/src/shared/service/b2b/graphql/product.ts index bd666651..8a44be4c 100644 --- a/apps/storefront/src/shared/service/b2b/graphql/product.ts +++ b/apps/storefront/src/shared/service/b2b/graphql/product.ts @@ -73,6 +73,24 @@ const searchProducts = (data: CustomFieldItems) => `{ } }` +const productsBulkUploadCSV = (data: CustomFieldItems) => `mutation { + productUpload ( + productListData: { + currencyCode: "${data.currencyCode || ''}" + productList: ${convertArrayToGraphql(data.productList || [])} + ${!data?.channelId ? '' : `id: ${data.channelId}`} + } + ) { + result { + errorFile, + errorProduct, + validProduct, + stockErrorFile, + stockErrorSkus, + } + } +}` + export const getB2BVariantInfoBySkus = (data: CustomFieldItems = {}, customMessage = false): CustomFieldItems => B3Request.graphqlB2B({ query: getVariantInfoBySkus(data), }, customMessage) @@ -92,3 +110,11 @@ export const searchBcProducts = (data: CustomFieldItems = {}): CustomFieldItems export const getBcVariantInfoBySkus = (data: CustomFieldItems = {}): CustomFieldItems => B3Request.graphqlProxyBC({ query: getVariantInfoBySkus(data), }) + +export const B2BProductsBulkUploadCSV = (data: CustomFieldItems = {}): CustomFieldItems => B3Request.graphqlB2B({ + query: productsBulkUploadCSV(data), +}) + +export const BcProductsBulkUploadCSV = (data: CustomFieldItems = {}): CustomFieldItems => B3Request.graphqlB2B({ + query: productsBulkUploadCSV(data), +}) diff --git a/apps/storefront/src/shared/service/b2b/index.ts b/apps/storefront/src/shared/service/b2b/index.ts index de510f4c..941e2989 100644 --- a/apps/storefront/src/shared/service/b2b/index.ts +++ b/apps/storefront/src/shared/service/b2b/index.ts @@ -100,6 +100,8 @@ import { getBcVariantInfoBySkus, searchB2BProducts, searchBcProducts, + B2BProductsBulkUploadCSV, + BcProductsBulkUploadCSV, } from './graphql/product' import { @@ -222,4 +224,6 @@ export { deleteBcShoppingListItem, searchBcProducts, getBcVariantInfoBySkus, + B2BProductsBulkUploadCSV, + BcProductsBulkUploadCSV, }