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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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} </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} </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} </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}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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