feat(admin-ui, medusa-js, medusa-react, medusa): Multiwarehousing UI (#3403)

* add "get-variant" endpoint

* import from a different place

* fix unit test

* add changeset

* inventory management for orders

* add changeset

* initial create-fulfillment

* add changeset

* type oas and admin

* Move inv. creation and listing from admin repo

* Fix location editing bug (CORE-1216)

* Fix default warehouse on inventory table view

* remove actions from each table line

* Use feature flag hook instead of context directly

* remove manage inventory action if inventory management is not enabled

* Address review comments

* fix queries made when inventorymodules are disabled

* variant form changes for feature enabled

* move exclamation icon into warning icon

* ensure queries are not run unless feature is enabled for create-fulfillment

---------

Co-authored-by: Philip Korsholm <philip.korsholm@hotmail.com>
Co-authored-by: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com>
This commit is contained in:
Rares Stefan
2023-03-08 16:08:56 +01:00
committed by GitHub
parent 53eda215e0
commit 57d7728dd9
49 changed files with 3922 additions and 722 deletions

View File

@@ -0,0 +1,24 @@
import IconProps from "../types/icon-type"
import React from "react"
const CircleQuarterSolid: React.FC<IconProps> = ({
size = "24",
color = "currentColor",
...attributes
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...attributes}
>
<circle cx="10" cy="10" r="7.25" stroke={color} strokeWidth="1.5" />
<path d="M15 10C15 7.23858 12.7614 5 10 5V10H15Z" fill={color} />
</svg>
)
}
export default CircleQuarterSolid

View File

@@ -1,6 +1,45 @@
import React, { FC } from "react"
import { FC } from "react"
import IconProps from "./types/icon-type"
type WarningCircleIconProps = IconProps & {
fillType?: "solid" | "outline"
}
const WarningCircleIcon: FC<WarningCircleIconProps> = ({
fillType = "outline",
...attributes
}) => {
if (fillType === "outline") {
return <WarningCircle {...attributes} />
} else {
return <ExclamationCircle {...attributes} />
}
}
const ExclamationCircle: FC<IconProps> = ({
size = "24",
color = "currentColor",
...attributes
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...attributes}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18 10C18 12.1217 17.1571 14.1566 15.6569 15.6569C14.1566 17.1571 12.1217 18 10 18C7.87827 18 5.84344 17.1571 4.34315 15.6569C2.84285 14.1566 2 12.1217 2 10C2 7.87827 2.84285 5.84344 4.34315 4.34315C5.84344 2.84285 7.87827 2 10 2C12.1217 2 14.1566 2.84285 15.6569 4.34315C17.1571 5.84344 18 7.87827 18 10ZM10 5C10.1989 5 10.3897 5.07902 10.5303 5.21967C10.671 5.36032 10.75 5.55109 10.75 5.75V10.25C10.75 10.4489 10.671 10.6397 10.5303 10.7803C10.3897 10.921 10.1989 11 10 11C9.80109 11 9.61032 10.921 9.46967 10.7803C9.32902 10.6397 9.25 10.4489 9.25 10.25V5.75C9.25 5.55109 9.32902 5.36032 9.46967 5.21967C9.61032 5.07902 9.80109 5 10 5ZM10 15C10.2652 15 10.5196 14.8946 10.7071 14.7071C10.8946 14.5196 11 14.2652 11 14C11 13.7348 10.8946 13.4804 10.7071 13.2929C10.5196 13.1054 10.2652 13 10 13C9.73478 13 9.48043 13.1054 9.29289 13.2929C9.10536 13.4804 9 13.7348 9 14C9 14.2652 9.10536 14.5196 9.29289 14.7071C9.48043 14.8946 9.73478 15 10 15Z"
fill={color}
/>
</svg>
)
}
const WarningCircle: FC<IconProps> = (props) => {
const { fill, size, ...attributes } = props
const line = fill || "#111827"
@@ -39,4 +78,4 @@ const WarningCircle: FC<IconProps> = (props) => {
)
}
export default WarningCircle
export default WarningCircleIcon

View File

@@ -1,4 +1,4 @@
import clsx from "clsx"
import InputHeader, { InputHeaderProps } from "../../fundamentals/input-header"
import React, {
ChangeEventHandler,
FocusEventHandler,
@@ -6,10 +6,11 @@ import React, {
useImperativeHandle,
useRef,
} from "react"
import InputError from "../../atoms/input-error"
import MinusIcon from "../../fundamentals/icons/minus-icon"
import PlusIcon from "../../fundamentals/icons/plus-icon"
import InputHeader, { InputHeaderProps } from "../../fundamentals/input-header"
import clsx from "clsx"
export type InputProps = Omit<React.ComponentPropsWithRef<"input">, "prefix"> &
InputHeaderProps & {
@@ -21,9 +22,11 @@ export type InputProps = Omit<React.ComponentPropsWithRef<"input">, "prefix"> &
onFocus?: FocusEventHandler<HTMLInputElement>
errors?: { [x: string]: unknown }
prefix?: React.ReactNode
suffix?: React.ReactNode
props?: React.HTMLAttributes<HTMLDivElement>
}
// eslint-disable-next-line react/display-name
const InputField = React.forwardRef<HTMLInputElement, InputProps>(
(
{
@@ -39,6 +42,7 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
tooltipContent,
tooltip,
prefix,
suffix,
errors,
props,
className,
@@ -89,9 +93,9 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
)}
<div
className={clsx(
"w-full flex items-center bg-grey-5 border border-gray-20 px-small py-xsmall rounded-rounded focus-within:shadow-input focus-within:border-violet-60",
"bg-grey-5 border-gray-20 px-small py-xsmall rounded-rounded focus-within:shadow-input focus-within:border-violet-60 flex w-full items-center border",
{
"border-rose-50 focus-within:shadow-cta focus-within:shadow-rose-60/10 focus-within:border-rose-50":
"focus-within:shadow-cta focus-within:shadow-rose-60/10 border-rose-50 focus-within:border-rose-50":
errors && name && errors[name],
},
small ? "h-8" : "h-10"
@@ -102,7 +106,7 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
) : null}
<input
className={clsx(
"bg-transparent outline-none outline-0 w-full remove-number-spinner leading-base text-grey-90 font-normal caret-violet-60 placeholder-grey-40",
"remove-number-spinner leading-base text-grey-90 caret-violet-60 placeholder-grey-40 w-full bg-transparent font-normal outline-none outline-0",
{ "text-small": small, "pt-[1px]": small }
)}
ref={inputRef}
@@ -114,11 +118,14 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
required={required}
{...fieldProps}
/>
{suffix ? (
<span className="mx-2xsmall text-grey-40">{suffix}</span>
) : null}
{deletable && (
<button
onClick={onDelete}
className="flex items-center justify-center w-4 h-4 pb-px ml-2 outline-none cursor-pointer text-grey-50 hover:bg-grey-10 focus:bg-grey-20 rounded-soft"
className="text-grey-50 hover:bg-grey-10 focus:bg-grey-20 rounded-soft ml-2 flex h-4 w-4 cursor-pointer items-center justify-center pb-px outline-none"
type="button"
>
&times;
@@ -126,11 +133,11 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
)}
{fieldProps.type === "number" && (
<div className="flex items-center self-end h-full">
<div className="flex h-full items-center self-end">
<button
onClick={onNumberDecrement}
onMouseDown={(e) => e.preventDefault()}
className="w-4 h-4 mr-2 outline-none cursor-pointer text-grey-50 hover:bg-grey-10 focus:bg-grey-20 rounded-soft"
className="text-grey-50 hover:bg-grey-10 focus:bg-grey-20 rounded-soft mr-2 h-4 w-4 cursor-pointer outline-none"
type="button"
>
<MinusIcon size={16} />
@@ -138,7 +145,7 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
<button
onMouseDown={(e) => e.preventDefault()}
onClick={onNumberIncrement}
className="w-4 h-4 outline-none cursor-pointer text-grey-50 hover:bg-grey-10 focus:bg-grey-20 rounded-soft"
className="text-grey-50 hover:bg-grey-10 focus:bg-grey-20 rounded-soft h-4 w-4 cursor-pointer outline-none"
type="button"
>
<PlusIcon size={16} />

View File

@@ -3,6 +3,7 @@ import * as Portal from "@radix-ui/react-portal"
import clsx from "clsx"
import React from "react"
import { useWindowDimensions } from "../../../hooks/use-window-dimensions"
import Button from "../../fundamentals/button"
import CrossIcon from "../../fundamentals/icons/cross-icon"
type ModalState = {
@@ -42,7 +43,7 @@ type ModalType = React.FC<ModalProps> & {
const Overlay: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<Dialog.Overlay className="fixed bg-grey-90/40 z-50 grid top-0 left-0 right-0 bottom-0 place-items-center overflow-y-auto">
<Dialog.Overlay className="fixed top-0 bottom-0 left-0 right-0 z-50 grid overflow-y-auto bg-grey-90/40 place-items-center">
{children}
</Dialog.Overlay>
)
@@ -56,7 +57,7 @@ const Content: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<Dialog.Content
style={style}
className="bg-grey-0 min-w-modal rounded-rounded overflow-x-hidden"
className="overflow-x-hidden min-w-modal rounded-rounded bg-grey-0"
>
{children}
</Dialog.Content>
@@ -108,7 +109,7 @@ Modal.Content = ({ children, className }) => {
<div
style={style}
className={clsx(
"px-7 pt-5 overflow-y-auto",
"overflow-y-auto px-8 pt-6",
{
["w-largeModal pb-7"]: isLargeModal,
["pb-5"]: !isLargeModal,
@@ -124,17 +125,22 @@ Modal.Content = ({ children, className }) => {
Modal.Header = ({ handleClose = undefined, children }) => {
return (
<div
className="pl-7 pt-3.5 pr-3.5 flex flex-col w-full"
className="flex items-center w-full px-8 py-6 border-b"
onClick={(e) => e.stopPropagation()}
>
<div className="pb-1 flex w-full justify-end">
<div className="flex flex-grow">{children}</div>
<div className="self-end">
{handleClose && (
<button onClick={handleClose} className="text-grey-50 cursor-pointer">
<Button
variant="ghost"
size="small"
onClick={handleClose}
className="text-grey-50 cursor-pointer border p-1.5"
>
<CrossIcon size={20} />
</button>
</Button>
)}
</div>
{children}
</div>
)
}
@@ -146,9 +152,9 @@ Modal.Footer = ({ children, className }) => {
<div
onClick={(e) => e.stopPropagation()}
className={clsx(
"px-7 bottom-0 pb-5 flex w-full",
"bottom-0 flex w-full px-7 pb-5",
{
"border-t border-grey-20 pt-4": isLargeModal,
"border-grey-20 border-t pt-4": isLargeModal,
},
className
)}

View File

@@ -1,7 +1,7 @@
import clsx from "clsx"
import React, { ReactNode, useContext, useReducer } from "react"
import Button from "../../fundamentals/button"
import ArrowLeftIcon from "../../fundamentals/icons/arrow-left-icon"
import UTurnIcon from "../../fundamentals/icons/u-turn-icon"
import Modal, { ModalProps } from "../../molecules/modal"
enum LayeredModalActions {
@@ -112,13 +112,13 @@ const LayeredModal: React.FC<LayeredModalProps> = ({
<Button
variant="ghost"
size="small"
className="h-8 w-8 text-grey-50"
className="w-8 h-8 border text-grey-50"
onClick={screen.onBack}
>
<ArrowLeftIcon size={20} />
<UTurnIcon size={20} />
</Button>
<div className="flex items-center gap-x-2xsmall">
<h2 className="inter-xlarge-semibold ml-5">{screen.title}</h2>
<h2 className="ml-4 inter-xlarge-semibold">{screen.title}</h2>
{screen.subtitle && (
<span className="inter-xlarge-regular text-grey-50">
({screen.subtitle})

View File

@@ -1,15 +1,15 @@
import React, { ReactNode, useState } from "react"
import clsx from "clsx"
import { ReactNode, useState } from "react"
import Modal from "../../molecules/modal"
import Button from "../../fundamentals/button"
import FileIcon from "../../fundamentals/icons/file-icon"
import TrashIcon from "../../fundamentals/icons/trash-icon"
import DownloadIcon from "../../fundamentals/icons/download-icon"
import XCircleIcon from "../../fundamentals/icons/x-circle-icon"
import CheckCircleIcon from "../../fundamentals/icons/check-circle-icon"
import WarningCircle from "../../fundamentals/icons/warning-circle"
import CrossIcon from "../../fundamentals/icons/cross-icon"
import DownloadIcon from "../../fundamentals/icons/download-icon"
import FileIcon from "../../fundamentals/icons/file-icon"
import Modal from "../../molecules/modal"
import TrashIcon from "../../fundamentals/icons/trash-icon"
import WarningCircleIcon from "../../fundamentals/icons/warning-circle"
import XCircleIcon from "../../fundamentals/icons/x-circle-icon"
import clsx from "clsx"
type FileSummaryProps = {
name: string
@@ -34,16 +34,16 @@ function FileSummary(props: FileSummaryProps) {
<div className="relative">
<div
style={{ width: `${progress}%` }}
className="absolute bg-grey-5 h-full transition-width duration-150 ease-in-out"
className="bg-grey-5 transition-width absolute h-full duration-150 ease-in-out"
/>
<div className="relative flex items-center rounded-xl border border-1 mt-6">
<div className="border-1 relative mt-6 flex items-center rounded-xl border">
<div className="m-4">
<FileIcon size={30} fill={progress ? "#9CA3AF" : "#2DD4BF"} />
</div>
<div className="flex-1 my-6">
<div className="text-small leading-5 text-grey-90">{name}</div>
<div className="text-xsmall leading-4 text-grey-50">
<div className="my-6 flex-1">
<div className="text-small text-grey-90 leading-5">{name}</div>
<div className="text-xsmall text-grey-50 leading-4">
{status || formattedSize}
</div>
</div>
@@ -68,18 +68,18 @@ function UploadSummary(props: UploadSummaryProps) {
const { creations, updates, rejections, type } = props
return (
<div className="flex gap-6">
<div className="flex items-center text-small text-grey-90">
<div className="text-small text-grey-90 flex items-center">
<CheckCircleIcon color="#9CA3AF" className="mr-2" />
<span className="font-semibold"> {creations}&nbsp;</span> new {type}
</div>
{updates && (
<div className="flex items-center text-small text-grey-90">
<WarningCircle fill="#9CA3AF" className="mr-2" />
<div className="text-small text-grey-90 flex items-center">
<WarningCircleIcon fill="#9CA3AF" className="mr-2" />
<span className="font-semibold">{updates}&nbsp;</span> updates
</div>
)}
{rejections && (
<div className="flex items-center text-small text-grey-90">
<div className="text-small text-grey-90 flex items-center">
<XCircleIcon color="#9CA3AF" className="mr-2" />
<span className="font-semibold">{rejections}&nbsp;</span> rejections
</div>
@@ -124,7 +124,7 @@ function DropArea(props: DropAreaProps) {
onDragOver={onDragOver}
onDrop={handleFileDrop}
className={clsx(
"flex flex-col justify-center items-center border border-dashed rounded-xl mt-3 p-6",
"mt-3 flex flex-col items-center justify-center rounded-xl border border-dashed p-6",
{ "opacity-50": isDragOver }
)}
>
@@ -208,7 +208,7 @@ function UploadModal(props: UploadModalProps) {
<Modal.Body>
<Modal.Content>
<div className="flex justify-between">
<span className="text-2xl text-grey-90 inter-large-semibold py-4">
<span className="text-grey-90 inter-large-semibold py-4 text-2xl">
Import {fileTitle}
</span>
<button onClick={onClose} className="text-grey-50 cursor-pointer">
@@ -216,7 +216,7 @@ function UploadModal(props: UploadModalProps) {
</button>
</div>
<div className="text-grey-90 text-base inter-large-semibold mb-1">
<div className="text-grey-90 inter-large-semibold mb-1 text-base">
Import {fileTitle}
</div>
@@ -241,14 +241,14 @@ function UploadModal(props: UploadModalProps) {
// TODO: change this to actual progress once this we can track upload
progress={100}
action={
<a className="w-6 h-6 cursor-pointer" onClick={removeFile}>
<a className="h-6 w-6 cursor-pointer" onClick={removeFile}>
<TrashIcon stroke="#9CA3AF" />
</a>
}
/>
)}
<div className="text-grey-90 text-base inter-large-semibold mt-8">
<div className="text-grey-90 inter-large-semibold mt-8 text-base">
{description2Title}
</div>
@@ -259,7 +259,7 @@ function UploadModal(props: UploadModalProps) {
size={2967}
action={
<a
className="w-6 h-6 cursor-pointer"
className="h-6 w-6 cursor-pointer"
href={templateLink}
download
>
@@ -271,11 +271,11 @@ function UploadModal(props: UploadModalProps) {
<div className="h-2" />
</Modal.Content>
<Modal.Footer>
<div className="flex w-full h-8 justify-end">
<div className="flex h-8 w-full justify-end">
<div className="flex gap-2">
<Button
variant="secondary"
className="mr-2 text-small justify-center"
className="text-small mr-2 justify-center"
size="small"
onClick={onClose}
>

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from "react"
import { useMemo } from "react"
import { Controller } from "react-hook-form"
import { Column, useTable } from "react-table"
import { FormImage } from "../../../types/shared"
@@ -38,7 +38,7 @@ const ImageTable = ({ data, form, onDelete }: ImageTableProps) => {
return (
<div className="py-base ml-large">
<img
className="h-[80px] w-[80px] object-cover rounded"
className="h-[80px] w-[80px] rounded object-cover"
src={value}
/>
</div>
@@ -71,7 +71,7 @@ const ImageTable = ({ data, form, onDelete }: ImageTableProps) => {
},
{
Header: () => (
<div className="flex gap-x-[6px] items-center justify-center">
<div className="flex items-center justify-center gap-x-[6px]">
<span>Thumbnail</span>
<IconTooltip content="Select which image you want to use as the thumbnail for this product" />
</div>
@@ -97,7 +97,7 @@ const ImageTable = ({ data, form, onDelete }: ImageTableProps) => {
onClick={() => onDelete(row.index)}
variant="ghost"
size="small"
className="p-1 text-grey-40 cursor-pointer mx-6"
className="p-1 mx-6 cursor-pointer text-grey-40"
type="button"
>
<TrashIcon size={20} />
@@ -108,19 +108,14 @@ const ImageTable = ({ data, form, onDelete }: ImageTableProps) => {
]
}, [])
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
} = useTable({
columns,
data,
defaultColumn: {
width: "auto",
},
})
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
useTable({
columns,
data,
defaultColumn: {
width: "auto",
},
})
return (
<Table {...getTableProps()}>

View File

@@ -0,0 +1,471 @@
import { Cell, Row, TableRowProps, usePagination, useTable } from "react-table"
import {
InventoryItemDTO,
InventoryLevelDTO,
ProductVariant,
} from "@medusajs/medusa"
import React, { useEffect, useMemo, useState } from "react"
import {
useAdminInventoryItems,
useAdminStockLocations,
useAdminStore,
useAdminUpdateLocationLevel,
useAdminVariant,
} from "medusa-react"
import Button from "../../fundamentals/button"
import ImagePlaceholder from "../../fundamentals/image-placeholder"
import InputField from "../../molecules/input"
import InputHeader from "../../fundamentals/input-header"
import InventoryFilter from "../../../domain/inventory/filter-dropdown"
import Modal from "../../molecules/modal"
import { NextSelect } from "../../molecules/select/next-select"
import Spinner from "../../atoms/spinner"
import Table from "../../molecules/table"
import TableContainer from "../../../components/organisms/table-container"
import { getErrorMessage } from "../../../utils/error-messages"
import { isEmpty } from "lodash"
import qs from "qs"
import { useInventoryFilters } from "./use-inventory-filters"
import useInventoryTableColumn from "./use-inventory-column"
import { useLocation } from "react-router-dom"
import useNotification from "../../../hooks/use-notification"
import useToggleState from "../../../hooks/use-toggle-state"
const DEFAULT_PAGE_SIZE = 15
type InventoryTableProps = {}
const defaultQueryProps = {}
const LocationDropdown = ({
selectedLocation,
onChange,
}: {
selectedLocation: string
onChange: (id: string) => void
}) => {
const { stock_locations: locations, isLoading } = useAdminStockLocations()
useEffect(() => {
if (!selectedLocation && !isLoading && locations) {
onChange(locations[0].id)
}
}, [isLoading, locations, onChange, selectedLocation])
const selectedLocObj = useMemo(() => {
if (!isLoading && locations) {
return locations.find((l) => l.id === selectedLocation)
}
return null
}, [selectedLocation, locations, isLoading])
if (isLoading || !locations || !selectedLocObj) {
return null
}
return (
<div className="h-[40px] w-[200px]">
<NextSelect
isMulti={false}
onChange={(loc) => {
onChange(loc!.value)
}}
options={locations.map((l) => ({
label: l.name,
value: l.id,
}))}
value={{ value: selectedLocObj.id, label: selectedLocObj.name }}
/>
</div>
)
}
const InventoryTable: React.FC<InventoryTableProps> = () => {
const { store } = useAdminStore()
const location = useLocation()
const defaultQuery = useMemo(() => {
if (store) {
return {
...defaultQueryProps,
location_id: store.default_location_id,
}
}
return defaultQueryProps
}, [store])
const {
removeTab,
setTab,
saveTab,
availableTabs: filterTabs,
activeFilterTab,
reset,
paginate,
setFilters,
setLocationFilter,
filters,
setQuery: setFreeText,
queryObject,
representationObject,
} = useInventoryFilters(location.search, defaultQuery)
const offs = parseInt(queryObject.offset) || 0
const limit = parseInt(queryObject.limit)
const [query, setQuery] = useState(queryObject.query)
const [numPages, setNumPages] = useState(0)
const clearFilters = () => {
reset()
setQuery("")
}
const { inventory_items, isLoading, count } = useAdminInventoryItems(
{
...queryObject,
},
{
enabled: !!store,
}
)
useEffect(() => {
const controlledPageCount = Math.ceil(count! / queryObject.limit)
setNumPages(controlledPageCount)
}, [inventory_items])
const updateUrlFromFilter = (obj = {}) => {
const stringified = qs.stringify(obj)
window.history.replaceState(`/a/inventory`, "", `${`?${stringified}`}`)
}
const refreshWithFilters = () => {
const filterObj = representationObject
if (isEmpty(filterObj)) {
updateUrlFromFilter({ offset: 0, limit: DEFAULT_PAGE_SIZE })
} else {
updateUrlFromFilter(filterObj)
}
}
useEffect(() => {
refreshWithFilters()
}, [representationObject])
const [columns] = useInventoryTableColumn()
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
gotoPage,
canPreviousPage,
canNextPage,
pageCount,
nextPage,
previousPage,
// Get the state from the instance
state: { pageIndex },
} = useTable(
{
columns,
data: inventory_items || [],
manualPagination: true,
initialState: {
pageIndex: Math.floor(offs / limit),
pageSize: limit,
},
pageCount: numPages,
autoResetPage: false,
},
usePagination
)
// Debounced search
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
if (query) {
setFreeText(query)
gotoPage(0)
} else {
if (typeof query !== "undefined") {
// if we delete query string, we reset the table view
reset()
}
}
}, 400)
return () => clearTimeout(delayDebounceFn)
}, [query])
const handleNext = () => {
if (canNextPage) {
paginate(1)
nextPage()
}
}
const handlePrev = () => {
if (canPreviousPage) {
paginate(-1)
previousPage()
}
}
return (
<TableContainer
hasPagination
pagingState={{
count: count || 0,
offset: offs,
pageSize: offs + rows.length,
title: "Inventory Items",
currentPage: pageIndex + 1,
pageCount: pageCount,
nextPage: handleNext,
prevPage: handlePrev,
hasNext: canNextPage,
hasPrev: canPreviousPage,
}}
numberOfRows={limit}
isLoading={isLoading}
>
<Table
filteringOptions={
<InventoryFilter
filters={filters}
submitFilters={setFilters}
clearFilters={clearFilters}
tabs={filterTabs}
onTabClick={setTab}
activeTab={activeFilterTab}
onRemoveTab={removeTab}
onSaveTab={saveTab}
/>
}
enableSearch
handleSearch={setQuery}
searchValue={query}
tableActions={
<LocationDropdown
selectedLocation={
queryObject.location_id || store?.default_location_id
}
onChange={(id) => {
setLocationFilter(id)
gotoPage(0)
}}
/>
}
{...getTableProps()}
>
<Table.Head>
{headerGroups?.map((headerGroup) => {
const { key, ...rest } = headerGroup.getHeaderGroupProps()
return (
<Table.HeadRow key={key} {...rest}>
{headerGroup.headers.map((col) => {
const { key, ...rest } = col.getHeaderProps()
return (
<Table.HeadCell
className="min-w-[100px]"
key={key}
{...rest}
>
{col.render("Header")}
</Table.HeadCell>
)
})}
</Table.HeadRow>
)
})}
</Table.Head>
<Table.Body {...getTableBodyProps()}>
{rows.map((row) => {
prepareRow(row)
const { key, ...rest } = row.getRowProps()
return <InventoryRow row={row} key={key} {...rest} />
})}
</Table.Body>
</Table>
</TableContainer>
)
}
const InventoryRow = ({
row,
...rest
}: {
row: Row<
Partial<InventoryItemDTO> & {
location_levels?: InventoryLevelDTO[] | undefined
variants?: ProductVariant[] | undefined
}
>
} & TableRowProps) => {
const inventory = row.original
const {
state: isShowingAdjustAvailabilityModal,
open: showAdjustAvailabilityModal,
close: closeAdjustAvailabilityModal,
} = useToggleState()
return (
<Table.Row
color={"inherit"}
onClick={showAdjustAvailabilityModal}
forceDropdown
{...rest}
>
{row.cells.map((cell: Cell, index: number) => {
const { key, ...rest } = cell.getCellProps()
return (
<Table.Cell {...rest} key={key}>
{cell.render("Cell", { index })}
</Table.Cell>
)
})}
{isShowingAdjustAvailabilityModal && (
<AdjustAvailabilityModal
inventory={inventory}
handleClose={closeAdjustAvailabilityModal}
/>
)}
</Table.Row>
)
}
const AdjustAvailabilityModal = ({
inventory,
handleClose,
}: {
inventory: Partial<InventoryItemDTO> & {
location_levels?: InventoryLevelDTO[] | undefined
variants?: ProductVariant[] | undefined
}
handleClose: () => void
}) => {
const inventoryVariantId = inventory.variants?.[0]?.id
const locationLevel = inventory.location_levels?.[0]
const { variant, isLoading } = useAdminVariant(inventoryVariantId || "")
const {
mutate: updateLocationLevelForInventoryItem,
isLoading: isSubmitting,
} = useAdminUpdateLocationLevel(inventory.id!)
const notification = useNotification()
const [stockedQuantity, setStockedQuantity] = useState(
locationLevel?.stocked_quantity || 0
)
const disableSubmit =
stockedQuantity === (locationLevel?.stocked_quantity || 0) ||
!variant ||
!locationLevel
const onSubmit = () => {
updateLocationLevelForInventoryItem(
{
stockLocationId: locationLevel!.location_id,
stocked_quantity: stockedQuantity,
},
{
onSuccess: () => {
notification(
"Success",
"Inventory item updated successfully",
"success"
)
handleClose()
},
onError: (error) => {
notification("Error", getErrorMessage(error), "error")
},
}
)
}
return (
<Modal handleClose={handleClose}>
<Modal.Body>
<Modal.Header handleClose={handleClose}>
<h1 className="inter-large-semibold">Adjust availability</h1>
</Modal.Header>
<Modal.Content>
{isLoading ? (
<Spinner />
) : (
<div className="grid grid-cols-2">
<InputHeader label="Item" />
<InputHeader label="Quantity" />
<div className="flex flex-col">
<span className="pr-base">
<div className="float-left my-1.5 mr-4 flex h-[40px] w-[30px] items-center">
{variant?.product?.thumbnail ? (
<img
src={variant?.product?.thumbnail}
className="object-cover h-full rounded-rounded"
/>
) : (
<ImagePlaceholder />
)}
</div>
<div className="flex flex-col">
<span className="truncate">
{variant?.product?.title}
<span className="truncate text-grey-50">
({inventory.sku})
</span>
</span>
<span className="text-grey-50">
{variant?.options?.map((o) => (
<span key={o.id}>{o.value}</span>
))}
</span>
</div>
</span>
</div>
<InputField
onChange={(e) => setStockedQuantity(e.target.valueAsNumber)}
autoFocus
type="number"
value={stockedQuantity}
/>
</div>
)}
</Modal.Content>
</Modal.Body>
<Modal.Footer>
<div className="flex justify-end w-full gap-x-xsmall">
<Button
size="small"
variant="ghost"
className="border"
onClick={handleClose}
>
Cancel
</Button>
<Button
size="small"
variant="primary"
disabled={disableSubmit}
loading={isSubmitting}
onClick={onSubmit}
>
Save and close
</Button>
</div>
</Modal.Footer>
</Modal>
)
}
export default InventoryTable

View File

@@ -0,0 +1,71 @@
import { useMemo } from "react"
import ImagePlaceholder from "../../fundamentals/image-placeholder"
const useInventoryTableColumn = () => {
const columns = useMemo(
() => [
{
Header: "Item",
accessor: "title",
Cell: ({ row: { original } }) => {
return (
<div className="flex items-center">
<div className="my-1.5 mr-4 flex h-[40px] w-[30px] items-center">
{original.variants[0]?.product?.thumbnail ? (
<img
src={original.variants[0].product.thumbnail}
className="object-cover h-full rounded-soft"
/>
) : (
<ImagePlaceholder />
)}
</div>
{original.variants[0]?.product?.title || ""}
</div>
)
},
},
{
Header: "Variant",
Cell: ({ row: { original } }) => {
return <div>{original?.variants[0]?.title || "-"}</div>
},
},
{
Header: "SKU",
accessor: "sku",
Cell: ({ cell: { value } }) => value,
},
{
Header: "Incoming",
accessor: "incoming_quantity",
Cell: ({ row: { original } }) => (
<div>
{original.location_levels.reduce(
(acc, next) => acc + next.incoming_quantity,
0
)}
</div>
),
},
{
Header: "In stock",
accessor: "stocked_quantity",
Cell: ({ row: { original } }) => (
<div>
{original.location_levels.reduce(
(acc, next) => acc + next.stocked_quantity,
0
)}
</div>
),
},
],
[]
)
return [columns] as const
}
export default useInventoryTableColumn

View File

@@ -0,0 +1,466 @@
import { omit } from "lodash"
import qs from "qs"
import { useMemo, useReducer, useState } from "react"
import { relativeDateFormatToTimestamp } from "../../../utils/time"
type InventoryDateFilter = null | {
gt?: string
lt?: string
}
type InventoryFilterAction =
| { type: "setQuery"; payload: string | null }
| { type: "setFilters"; payload: InventoryFilterState }
| { type: "reset"; payload: InventoryFilterState }
| { type: "setOffset"; payload: number }
| { type: "setDefaults"; payload: InventoryDefaultFilters | null }
| { type: "setLocation"; payload: string }
| { type: "setLimit"; payload: number }
interface InventoryFilterState {
query?: string | null
limit: number
offset: number
location: string
additionalFilters: InventoryDefaultFilters | null
}
const allowedFilters = ["location", "q", "offset", "limit"]
const DefaultTabs = {}
const formatDateFilter = (filter: InventoryDateFilter) => {
if (filter === null) {
return filter
}
const dateFormatted = Object.entries(filter).reduce((acc, [key, value]) => {
if (value.includes("|")) {
acc[key] = relativeDateFormatToTimestamp(value)
} else {
acc[key] = value
}
return acc
}, {})
return dateFormatted
}
const reducer = (
state: InventoryFilterState,
action: InventoryFilterAction
): InventoryFilterState => {
switch (action.type) {
case "setFilters": {
return {
...state,
query: action?.payload?.query,
}
}
case "setQuery": {
return {
...state,
query: action.payload,
}
}
case "setLimit": {
return {
...state,
limit: action.payload,
}
}
case "setOffset": {
return {
...state,
offset: action.payload,
}
}
case "setLocation": {
return {
...state,
location: action.payload,
}
}
case "reset": {
return action.payload
}
default: {
return state
}
}
}
type InventoryDefaultFilters = {
expand?: string
fields?: string
location_id?: string
}
const eqSet = (as: Set<string>, bs: Set<string>) => {
if (as.size !== bs.size) {
return false
}
for (const a of as) {
if (!bs.has(a)) {
return false
}
}
return true
}
export const useInventoryFilters = (
existing?: string,
defaultFilters: InventoryDefaultFilters | null = null
) => {
if (existing && existing[0] === "?") {
existing = existing.substring(1)
}
const initial = useMemo(
() => parseQueryString(existing, defaultFilters),
[existing, defaultFilters]
)
const initialTabs = useMemo(() => {
const storageString = localStorage.getItem("inventory::filters")
if (storageString) {
const savedTabs = JSON.parse(storageString)
if (savedTabs) {
return Object.entries(savedTabs).map(([key, value]) => {
return {
label: key,
value: key,
removable: true,
representationString: value,
}
})
}
}
return []
}, [])
const [state, dispatch] = useReducer(reducer, initial)
const [tabs, setTabs] = useState(initialTabs)
const setDefaultFilters = (filters: InventoryDefaultFilters | null) => {
dispatch({ type: "setDefaults", payload: filters })
}
const setLimit = (limit: number) => {
dispatch({ type: "setLimit", payload: limit })
}
const setLocationFilter = (loc: string) => {
dispatch({ type: "setLocation", payload: loc })
}
const paginate = (direction: 1 | -1) => {
if (direction > 0) {
const nextOffset = state.offset + state.limit
dispatch({ type: "setOffset", payload: nextOffset })
} else {
const nextOffset = Math.max(state.offset - state.limit, 0)
dispatch({ type: "setOffset", payload: nextOffset })
}
}
const reset = () => {
dispatch({
type: "setFilters",
payload: {
...state,
offset: 0,
query: null,
},
})
}
const setFilters = (filters: InventoryFilterState) => {
dispatch({ type: "setFilters", payload: filters })
}
const setQuery = (queryString: string | null) => {
dispatch({ type: "setQuery", payload: queryString })
}
const getQueryObject = () => {
const toQuery: any = { ...state.additionalFilters }
for (const [key, value] of Object.entries(state)) {
if (key === "query") {
if (value && typeof value === "string") {
toQuery["q"] = value
}
} else if (key === "offset" || key === "limit") {
toQuery[key] = value
} else if (value.open) {
if (key === "date") {
toQuery[stateFilterMap[key]] = formatDateFilter(
value.filter as InventoryDateFilter
)
} else {
toQuery[stateFilterMap[key]] = value.filter
}
} else if (key === "location") {
toQuery[stateFilterMap[key]] = value
}
}
return toQuery
}
const getQueryString = () => {
const obj = getQueryObject()
return qs.stringify(obj, { skipNulls: true })
}
const getRepresentationObject = (fromObject?: InventoryFilterState) => {
const objToUse = fromObject ?? state
const toQuery: any = {}
for (const [key, value] of Object.entries(objToUse)) {
if (key === "query") {
if (value && typeof value === "string") {
toQuery["q"] = value
}
} else if (key === "offset" || key === "limit") {
toQuery[key] = value
} else if (value.open) {
toQuery[stateFilterMap[key]] = value.filter
}
}
return toQuery
}
const getRepresentationString = () => {
const obj = getRepresentationObject()
return qs.stringify(obj, { skipNulls: true })
}
const queryObject = useMemo(() => getQueryObject(), [state])
const representationObject = useMemo(() => getRepresentationObject(), [state])
const representationString = useMemo(() => getRepresentationString(), [state])
const activeFilterTab = useMemo(() => {
const clean = omit(representationObject, ["limit", "offset"])
const stringified = qs.stringify(clean)
const existsInSaved = tabs.find(
(el) => el.representationString === stringified
)
if (existsInSaved) {
return existsInSaved.value
}
for (const [tab, conditions] of Object.entries(DefaultTabs)) {
let match = true
if (Object.keys(clean).length !== Object.keys(conditions).length) {
continue
}
for (const [filter, value] of Object.entries(conditions)) {
if (filter in clean) {
if (Array.isArray(value)) {
match =
Array.isArray(clean[filter]) &&
eqSet(new Set(clean[filter]), new Set(value))
} else {
match = clean[filter] === value
}
} else {
match = false
}
if (!match) {
break
}
}
if (match) {
return tab
}
}
return null
}, [representationObject, tabs])
const availableTabs = useMemo(() => {
return [...tabs]
}, [tabs])
const setTab = (tabName: string) => {
let tabToUse: object | null = null
if (tabName in DefaultTabs) {
tabToUse = DefaultTabs[tabName]
} else {
const tabFound = tabs.find((t) => t.value === tabName)
if (tabFound) {
tabToUse = qs.parse(tabFound.representationString)
}
}
if (tabToUse) {
const toSubmit = {
...state,
}
for (const [filter, val] of Object.entries(tabToUse)) {
toSubmit[filterStateMap[filter]] = {
open: true,
filter: val,
}
}
dispatch({ type: "setFilters", payload: toSubmit })
}
}
const saveTab = (tabName: string, filters: InventoryFilterState) => {
const repObj = getRepresentationObject({ ...filters })
const clean = omit(repObj, ["limit", "offset"])
const repString = qs.stringify(clean, { skipNulls: true })
const storedString = localStorage.getItem("inventory::filters")
let existing: null | object = null
if (storedString) {
existing = JSON.parse(storedString)
}
if (existing) {
existing[tabName] = repString
localStorage.setItem("inventory::filters", JSON.stringify(existing))
} else {
const newFilters = {}
newFilters[tabName] = repString
localStorage.setItem("inventory::filters", JSON.stringify(newFilters))
}
setTabs((prev) => {
const duplicate = prev.findIndex(
(prev) => prev.label?.toLowerCase() === tabName.toLowerCase()
)
if (duplicate !== -1) {
prev.splice(duplicate, 1)
}
return [
...prev,
{
label: tabName,
value: tabName,
representationString: repString,
removable: true,
},
]
})
dispatch({ type: "setFilters", payload: filters })
}
const removeTab = (tabValue: string) => {
const storedString = localStorage.getItem("products::filters")
let existing: null | object = null
if (storedString) {
existing = JSON.parse(storedString)
}
if (existing) {
delete existing[tabValue]
localStorage.setItem("products::filters", JSON.stringify(existing))
}
setTabs((prev) => {
const newTabs = prev.filter((p) => p.value !== tabValue)
return newTabs
})
}
return {
...state,
filters: {
...state,
},
removeTab,
saveTab,
setTab,
availableTabs,
activeFilterTab,
representationObject,
representationString,
queryObject,
paginate,
getQueryObject,
getQueryString,
setQuery,
setFilters,
setDefaultFilters,
setLocationFilter,
setLimit,
reset,
}
}
const filterStateMap = {
location_id: "location",
}
const stateFilterMap = {
location: "location_id",
}
const parseQueryString = (
queryString?: string,
additionals: InventoryDefaultFilters | null = null
): InventoryFilterState => {
const defaultVal: InventoryFilterState = {
location: additionals?.location_id ?? "",
offset: 0,
limit: 15,
additionalFilters: additionals,
}
if (queryString) {
const filters = qs.parse(queryString)
for (const [key, value] of Object.entries(filters)) {
if (allowedFilters.includes(key)) {
switch (key) {
case "offset": {
if (typeof value === "string") {
defaultVal.offset = parseInt(value)
}
break
}
case "limit": {
if (typeof value === "string") {
defaultVal.limit = parseInt(value)
}
break
}
case "location_id": {
if (typeof value === "string") {
defaultVal.location = value
}
break
}
case "q": {
if (typeof value === "string") {
defaultVal.query = value
}
break
}
default: {
break
}
}
}
}
}
return defaultVal
}

View File

@@ -46,7 +46,7 @@ const useProductTableColumn = ({ setTileView, setListView, showList }) => {
{original.thumbnail ? (
<img
src={original.thumbnail}
className="rounded-soft h-full object-cover"
className="object-cover h-full rounded-soft"
/>
) : (
<ImagePlaceholder />