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:
co-authored by
Philip Korsholm
Philip Korsholm
parent
53eda215e0
commit
57d7728dd9
@@ -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()}>
|
||||
|
||||
@@ -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
|
||||
+71
@@ -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
|
||||
+466
@@ -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
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user