feat(dashboard): inventory create flow (#7650)

This commit is contained in:
Frane Polić
2024-06-10 17:37:36 +02:00
committed by GitHub
parent 69410162f6
commit f08f0d6cc9
37 changed files with 1010 additions and 271 deletions

View File

@@ -5,7 +5,12 @@ import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridNumberCell = <TData, TValue = any>({
field,
context,
}: DataGridCellProps<TData, TValue>) => {
...rest
}: DataGridCellProps<TData, TValue> & {
min?: number
max?: number
placeholder?: string
}) => {
const { register, attributes, container } = useDataGridCell({
field,
context,
@@ -18,7 +23,19 @@ export const DataGridNumberCell = <TData, TValue = any>({
type="number"
{...register(field, {
valueAsNumber: true,
onChange: (e) => {
if (e.target.value) {
const parsedValue = Number(e.target.value)
if (Number.isNaN(parsedValue)) {
return undefined
}
return parsedValue
}
},
})}
className="h-full w-full bg-transparent p-2 text-right [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
{...rest}
/>
</DataGridCellContainer>
)

View File

@@ -1,4 +1,4 @@
import { AdminInventoryItemResponse, InventoryNext } from "@medusajs/types"
import { HttpTypes } from "@medusajs/types"
import {
QueryKey,
UseMutationOptions,
@@ -6,19 +6,9 @@ import {
useMutation,
useQuery,
} from "@tanstack/react-query"
import {
InventoryItemLocationBatch,
UpdateInventoryItemReq,
UpdateInventoryLevelReq,
} from "../../types/api-payloads"
import {
InventoryItemDeleteRes,
InventoryItemListRes,
InventoryItemLocationLevelsRes,
InventoryItemRes,
} from "../../types/api-responses"
import { FetchError } from "@medusajs/js-sdk"
import { client } from "../../lib/client"
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
@@ -36,16 +26,16 @@ export const useInventoryItems = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<
InventoryItemListRes,
Error,
InventoryItemListRes,
HttpTypes.AdminInventoryItemListResponse,
FetchError,
HttpTypes.AdminInventoryItemListResponse,
QueryKey
>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => client.inventoryItems.list(query),
queryFn: () => sdk.admin.inventoryItem.list(query),
queryKey: inventoryItemsQueryKeys.list(query),
...options,
})
@@ -57,12 +47,17 @@ export const useInventoryItem = (
id: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<InventoryItemRes, Error, InventoryItemRes, QueryKey>,
UseQueryOptions<
HttpTypes.AdminInventoryItemResponse,
FetchError,
HttpTypes.AdminInventoryItemResponse,
QueryKey
>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => client.inventoryItems.retrieve(id, query),
queryFn: () => sdk.admin.inventoryItem.retrieve(id, query),
queryKey: inventoryItemsQueryKeys.detail(id),
...options,
})
@@ -70,13 +65,37 @@ export const useInventoryItem = (
return { ...data, ...rest }
}
export const useUpdateInventoryItem = (
id: string,
options?: UseMutationOptions<InventoryItemRes, Error, UpdateInventoryItemReq>
export const useCreateInventoryItem = (
options?: UseMutationOptions<
HttpTypes.AdminInventoryItemResponse,
FetchError,
HttpTypes.AdminCreateInventoryItem
>
) => {
return useMutation({
mutationFn: (payload: InventoryNext.UpdateInventoryItemInput) =>
client.inventoryItems.update(id, payload),
mutationFn: (payload: HttpTypes.AdminCreateInventoryItem) =>
sdk.admin.inventoryItem.create(payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateInventoryItem = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminInventoryItemResponse,
FetchError,
HttpTypes.AdminUpdateInventoryItem
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminUpdateInventoryItem) =>
sdk.admin.inventoryItem.update(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
@@ -92,10 +111,14 @@ export const useUpdateInventoryItem = (
export const useDeleteInventoryItem = (
id: string,
options?: UseMutationOptions<InventoryItemDeleteRes, Error, void>
options?: UseMutationOptions<
HttpTypes.AdminInventoryItemDeleteResponse,
FetchError,
void
>
) => {
return useMutation({
mutationFn: () => client.inventoryItems.delete(id),
mutationFn: () => sdk.admin.inventoryItem.delete(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
@@ -112,14 +135,15 @@ export const useDeleteInventoryItem = (
export const useDeleteInventoryItemLevel = (
inventoryItemId: string,
locationId: string,
options?: UseMutationOptions<InventoryItemDeleteRes, Error, void>
options?: UseMutationOptions<
HttpTypes.AdminInventoryItemDeleteResponse,
FetchError,
void
>
) => {
return useMutation({
mutationFn: () =>
client.inventoryItems.deleteInventoryItemLevel(
inventoryItemId,
locationId
),
sdk.admin.inventoryItem.deleteLevel(inventoryItemId, locationId),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
@@ -141,17 +165,16 @@ export const useInventoryItemLevels = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<
InventoryItemLocationLevelsRes,
Error,
InventoryItemLocationLevelsRes,
HttpTypes.AdminInventoryLevelListResponse,
FetchError,
HttpTypes.AdminInventoryLevelListResponse,
QueryKey
>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () =>
client.inventoryItems.listLocationLevels(inventoryItemId, query),
queryFn: () => sdk.admin.inventoryItem.listLevels(inventoryItemId, query),
queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId),
...options,
})
@@ -159,22 +182,18 @@ export const useInventoryItemLevels = (
return { ...data, ...rest }
}
export const useUpdateInventoryItemLevel = (
export const useUpdateInventoryLevel = (
inventoryItemId: string,
locationId: string,
options?: UseMutationOptions<
AdminInventoryItemResponse,
Error,
UpdateInventoryLevelReq
HttpTypes.AdminInventoryItemResponse,
FetchError,
HttpTypes.AdminUpdateInventoryLevel
>
) => {
return useMutation({
mutationFn: (payload: UpdateInventoryLevelReq) =>
client.inventoryItems.updateInventoryLevel(
inventoryItemId,
locationId,
payload
),
mutationFn: (payload: HttpTypes.AdminUpdateInventoryLevel) =>
sdk.admin.inventoryItem.updateLevel(inventoryItemId, locationId, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
@@ -191,17 +210,17 @@ export const useUpdateInventoryItemLevel = (
})
}
export const useBatchInventoryItemLevels = (
export const useBatchUpdateInventoryLevels = (
inventoryItemId: string,
options?: UseMutationOptions<
InventoryItemLocationLevelsRes,
Error,
InventoryItemLocationBatch
HttpTypes.AdminInventoryItemResponse,
FetchError,
HttpTypes.AdminBatchUpdateInventoryLevelLocation
>
) => {
return useMutation({
mutationFn: (payload: InventoryItemLocationBatch) =>
client.inventoryItems.batchPostLocationLevels(inventoryItemId, payload),
mutationFn: (payload: HttpTypes.AdminBatchUpdateInventoryLevelLocation) =>
sdk.admin.inventoryItem.batchUpdateLevels(inventoryItemId, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),

View File

@@ -403,6 +403,15 @@
"associatedVariants": "Associated variants",
"manageLocations": "Manage locations",
"deleteWarning": "You are about to delete an inventory item. This action cannot be undone.",
"create": {
"title": "Add item",
"details": "Details",
"availability": "Availability",
"locations": "Locations",
"attributes": "Attributes",
"requiresShipping": "Requires shipping",
"requiresShippingHint": "Does the inventory item require shipping?"
},
"reservation": {
"header": "Reservation of {{itemName}}",
"editItemDetails": "Edit item details",

View File

@@ -6,7 +6,6 @@ import { currencies } from "./currencies"
import { customerGroups } from "./customer-groups"
import { fulfillmentProviders } from "./fulfillment-providers"
import { fulfillments } from "./fulfillments"
import { inventoryItems } from "./inventory"
import { invites } from "./invites"
import { payments } from "./payments"
import { priceLists } from "./price-lists"
@@ -40,7 +39,6 @@ export const client = {
productTags: tags,
users: users,
invites: invites,
inventoryItems: inventoryItems,
reservations: reservations,
fulfillments: fulfillments,
fulfillmentProviders: fulfillmentProviders,

View File

@@ -1,108 +0,0 @@
import {
AdminInventoryItemListResponse,
AdminInventoryItemResponse,
AdminInventoryLevelResponse,
} from "@medusajs/types"
import {
CreateInventoryItemReq,
InventoryItemLocationBatch,
UpdateInventoryItemReq,
UpdateInventoryLevelReq,
} from "../../types/api-payloads"
import {
InventoryItemLevelDeleteRes,
InventoryItemLocationLevelsRes,
} from "../../types/api-responses"
import { deleteRequest, getRequest, postRequest } from "./common"
async function retrieveInventoryItem(id: string, query?: Record<string, any>) {
return getRequest<AdminInventoryItemResponse>(
`/admin/inventory-items/${id}`,
query
)
}
async function listInventoryItems(query?: Record<string, any>) {
return getRequest<AdminInventoryItemListResponse>(
`/admin/inventory-items`,
query
)
}
async function createInventoryItem(payload: CreateInventoryItemReq) {
return postRequest<AdminInventoryItemResponse>(
`/admin/inventory-items`,
payload
)
}
async function updateInventoryItem(
id: string,
payload: UpdateInventoryItemReq
) {
return postRequest<AdminInventoryItemResponse>(
`/admin/inventory-items/${id}`,
payload
)
}
async function deleteInventoryItem(id: string) {
return deleteRequest<AdminInventoryItemResponse>(
`/admin/inventory-items/${id}`
)
}
async function listInventoryItemLevels(
id: string,
query?: Record<string, any>
) {
return getRequest<InventoryItemLocationLevelsRes>(
`/admin/inventory-items/${id}/location-levels`,
query
)
}
async function deleteInventoryItemLevel(
inventoryItemId: string,
locationId: string
) {
return deleteRequest<InventoryItemLevelDeleteRes>(
`/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}`
)
}
async function updateInventoryLevel(
inventoryItemId: string,
locationId: string,
payload: UpdateInventoryLevelReq
) {
return postRequest<AdminInventoryItemResponse>(
`/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}`,
payload
)
}
async function batchPostLocationLevels(
inventoryItemId: string,
payload: InventoryItemLocationBatch
) {
return postRequest<AdminInventoryLevelResponse>(
`/admin/inventory-items/${inventoryItemId}/location-levels/batch`,
{
create: payload.creates,
delete: payload.deletes,
}
)
}
export const inventoryItems = {
retrieve: retrieveInventoryItem,
list: listInventoryItems,
create: createInventoryItem,
update: updateInventoryItem,
delete: deleteInventoryItem,
listLocationLevels: listInventoryItemLevels,
updateInventoryLevel,
deleteInventoryItemLevel,
batchPostLocationLevels,
}

View File

@@ -13,7 +13,7 @@ import { ProtectedRoute } from "../../components/authentication/protected-route"
import { MainLayout } from "../../components/layout/main-layout"
import { SettingsLayout } from "../../components/layout/settings-layout"
import { ErrorBoundary } from "../../components/utilities/error-boundary"
import { InventoryItemRes, PriceListRes } from "../../types/api-responses"
import { PriceListRes } from "../../types/api-responses"
import { RouteExtensions } from "./route-extensions"
import { SettingsExtensions } from "./settings-extensions"
@@ -467,12 +467,19 @@ export const RouteMap: RouteObject[] = [
{
path: "",
lazy: () => import("../../routes/inventory/inventory-list"),
children: [
{
path: "create",
lazy: () =>
import("../../routes/inventory/inventory-create"),
},
],
},
{
path: ":id",
lazy: () => import("../../routes/inventory/inventory-detail"),
handle: {
crumb: (data: InventoryItemRes) =>
crumb: (data: HttpTypes.AdminInventoryItemResponse) =>
data.inventory_item.title ?? data.inventory_item.sku,
},
children: [

View File

@@ -0,0 +1,71 @@
import { useMemo } from "react"
import { StockLocationDTO } from "@medusajs/types"
import { UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { DataGridRoot } from "../../../../../components/data-grid/data-grid-root"
import { createDataGridHelper } from "../../../../../components/data-grid/utils"
import { DataGridReadOnlyCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-readonly-cell"
import { DataGridNumberCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-number-cell"
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
type Props = {
form: UseFormReturn<{}>
}
export const CreateInventoryAvailabilityForm = ({ form }: Props) => {
const { isPending, stock_locations = [] } = useStockLocations({ limit: 999 })
const columns = useColumns()
return (
<div className="flex size-full flex-col divide-y overflow-hidden">
{isPending ? (
<div>Loading...</div>
) : (
<DataGridRoot columns={columns} data={stock_locations} state={form} />
)}
</div>
)
}
const columnHelper = createDataGridHelper<StockLocationDTO>()
const useColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.column({
id: "location",
header: () => (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{t("locations.domain")}</span>
</div>
),
cell: ({ row }) => {
return (
<DataGridReadOnlyCell>{row.original.name}</DataGridReadOnlyCell>
)
},
disableHidding: true,
}),
columnHelper.column({
id: "in-stock",
name: t("fields.inStock"),
header: t("fields.inStock"),
cell: (context) => {
return (
<DataGridNumberCell
min={0}
placeholder="0"
context={context}
field={`locations.${context.row.original.id}`}
/>
)
},
}),
],
[t]
)
}

View File

@@ -0,0 +1,553 @@
import { zodResolver } from "@hookform/resolvers/zod"
import React, { useEffect } from "react"
import { useForm } from "react-hook-form"
import * as zod from "zod"
import {
Button,
Heading,
ProgressStatus,
ProgressTabs,
clx,
Input,
Textarea,
Switch,
toast,
} from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { CreateInventoryAvailabilityForm } from "./create-inventory-availability-form"
import { CountrySelect } from "../../../../../components/inputs/country-select"
import { Form } from "../../../../../components/common/form"
import {
inventoryItemsQueryKeys,
useCreateInventoryItem,
} from "../../../../../hooks/api/inventory"
import { sdk } from "../../../../../lib/client"
import { optionalInt } from "../../../../../lib/validation"
import { queryClient } from "../../../../../lib/query-client"
enum Tab {
DETAILS = "details",
AVAILABILITY = "availability",
}
type StepStatus = {
[key in Tab]: ProgressStatus
}
const CreateInventoryItemSchema = zod.object({
title: zod.string().min(1),
sku: zod.string().optional(),
hs_code: zod.string().optional(),
weight: optionalInt,
length: optionalInt,
height: optionalInt,
width: optionalInt,
origin_country: zod.string().optional(),
mid_code: zod.string().optional(),
material: zod.string().optional(),
description: zod.string().optional(),
requires_shipping: zod.boolean().optional(),
thumbnail: zod.string().optional(),
locations: zod.record(zod.string(), zod.number().optional()).optional(),
// metadata: zod.record(zod.string(), zod.unknown()).optional(),
})
type CreateInventoryItemFormProps = {}
export function CreateInventoryItemForm({}: CreateInventoryItemFormProps) {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const [tab, setTab] = React.useState<Tab>(Tab.DETAILS)
const form = useForm<zod.infer<typeof CreateInventoryItemSchema>>({
defaultValues: {
title: "",
sku: "",
hs_code: "",
weight: "",
length: "",
height: "",
width: "",
origin_country: "",
mid_code: "",
material: "",
description: "",
requires_shipping: true,
thumbnail: "",
},
resolver: zodResolver(CreateInventoryItemSchema),
})
const { mutateAsync: createInventoryItem, isPending: isLoading } =
useCreateInventoryItem()
const handleSubmit = form.handleSubmit(async (data) => {
let { locations, ...payload } = data
for (const k in payload) {
if (payload[k] === "") {
delete payload[k]
continue
}
if (["weight", "length", "height", "width"].includes(k)) {
payload[k] = parseInt(payload[k])
}
}
try {
const { inventory_item } = await createInventoryItem(payload)
try {
await sdk.admin.inventoryItem.batchUpdateLevels(inventory_item.id, {
create: Object.entries(locations)
.filter(([_, quantiy]) => !!quantiy)
.map(([location_id, stocked_quantity]) => ({
location_id,
stocked_quantity,
})),
})
await queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
})
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
}
handleSuccess()
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
}
})
const [status, setStatus] = React.useState<StepStatus>({
[Tab.AVAILABILITY]: "not-started",
[Tab.DETAILS]: "not-started",
})
const onTabChange = React.useCallback(async (value: Tab) => {
const result = await form.trigger()
if (!result) {
return
}
setTab(value)
}, [])
const onNext = React.useCallback(async () => {
const result = await form.trigger()
if (!result) {
return
}
switch (tab) {
case Tab.DETAILS: {
setTab(Tab.AVAILABILITY)
break
}
case Tab.AVAILABILITY:
break
}
}, [tab])
useEffect(() => {
if (form.formState.isDirty) {
setStatus((prev) => ({ ...prev, [Tab.DETAILS]: "in-progress" }))
} else {
setStatus((prev) => ({ ...prev, [Tab.DETAILS]: "not-started" }))
}
}, [form.formState.isDirty])
useEffect(() => {
if (tab === Tab.DETAILS && form.formState.isDirty) {
setStatus((prev) => ({ ...prev, [Tab.DETAILS]: "in-progress" }))
}
if (tab === Tab.AVAILABILITY) {
setStatus((prev) => ({
...prev,
[Tab.DETAILS]: "completed",
[Tab.AVAILABILITY]: "in-progress",
}))
}
}, [tab])
return (
<RouteFocusModal.Form form={form}>
<form
className="flex h-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<ProgressTabs
value={tab}
className="h-full"
onValueChange={(tab) => onTabChange(tab as Tab)}
>
<RouteFocusModal.Header>
<ProgressTabs.List className="border-ui-border-base -my-2 ml-2 min-w-0 flex-1 border-l">
<ProgressTabs.Trigger
value={Tab.DETAILS}
status={status[Tab.DETAILS]}
className="w-full max-w-[200px]"
>
<span className="w-full cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
{t("inventory.create.details")}
</span>
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
value={Tab.AVAILABILITY}
className="w-full max-w-[200px]"
status={status[Tab.AVAILABILITY]}
>
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{t("inventory.create.availability")}
</span>
</ProgressTabs.Trigger>
</ProgressTabs.List>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
size="small"
className="whitespace-nowrap"
isLoading={isLoading}
onClick={tab !== Tab.AVAILABILITY ? onNext : undefined}
key={tab === Tab.AVAILABILITY ? "details" : "pricing"}
type={tab === Tab.AVAILABILITY ? "submit" : "button"}
>
{tab === Tab.AVAILABILITY
? t("actions.save")
: t("general.next")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body
className={clx(
"flex h-full w-fit flex-col items-center divide-y overflow-hidden",
{ "mx-auto": tab === Tab.DETAILS }
)}
>
<ProgressTabs.Content value={Tab.DETAILS} className="h-full w-full">
<div className="container mx-auto w-[720px] px-1 py-8">
<Heading level="h2" className="mb-12 mt-8 text-2xl">
{t("inventory.create.title")}
</Heading>
<div className="flex flex-col gap-y-6">
<div className="grid grid-cols-1 gap-x-3 gap-y-6 lg:grid-cols-2">
<Form.Field
control={form.control}
name="title"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.title")}</Form.Label>
<Form.Control>
<Input
{...field}
placeholder={t("fields.title")}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="sku"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.sku")}</Form.Label>
<Form.Control>
<Input {...field} placeholder="sku-123" />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="col-span-2">
<Form.Field
control={form.control}
name="description"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.description.label")}
</Form.Label>
<Form.Control>
<Textarea
{...field}
placeholder="The item description"
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<div className="col-span-2">
<Form.Field
control={form.control}
name="requires_shipping"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="flex flex-col gap-y-1">
<div className="flex items-center justify-between">
<Form.Label>
{t("inventory.create.requiresShipping")}
</Form.Label>
<Form.Control>
<Switch
checked={value}
onCheckedChange={(checked) =>
onChange(!!checked)
}
{...field}
/>
</Form.Control>
</div>
<Form.Hint>
{t("inventory.create.requiresShippingHint")}
</Form.Hint>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
{/*<Form.Field*/}
{/* className="col-span-1"*/}
{/* control={form.control}*/}
{/* name="location_ids"*/}
{/* render={({ field }) => {*/}
{/* return (*/}
{/* <Form.Item>*/}
{/* <Form.Label optional>*/}
{/* {t("inventory.create.locations")}*/}
{/* </Form.Label>*/}
{/* <Form.Control>*/}
{/* <Combobox*/}
{/* {...field}*/}
{/* multiple*/}
{/* options={locations.options}*/}
{/* searchValue={locations.searchValue}*/}
{/* onSearchValueChange={*/}
{/* locations.onSearchValueChange*/}
{/* }*/}
{/* fetchNextPage={locations.fetchNextPage}*/}
{/* />*/}
{/* </Form.Control>*/}
{/* <Form.ErrorMessage />*/}
{/* </Form.Item>*/}
{/* )*/}
{/* }}*/}
{/*/>*/}
</div>
<Heading level="h3" className="my-6">
{t("inventory.create.attributes")}
</Heading>
<div className="grid grid-cols-1 gap-x-4 gap-y-8 lg:grid-cols-2">
<Form.Field
control={form.control}
name="width"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.width.label")}
</Form.Label>
<Form.Control>
<Input
{...field}
type="number"
min={0}
placeholder="100"
/>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="length"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.length.label")}
</Form.Label>
<Form.Control>
<Input
{...field}
type="number"
min={0}
placeholder="100"
/>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="height"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.height.label")}
</Form.Label>
<Form.Control>
<Input
{...field}
type="number"
min={0}
placeholder="100"
/>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="weight"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.weight.label")}
</Form.Label>
<Form.Control>
<Input
{...field}
type="number"
min={0}
placeholder="100"
/>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="mid_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.mid_code.label")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="hs_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.hs_code.label")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="origin_country"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.countryOrigin.label")}
</Form.Label>
<Form.Control>
<CountrySelect {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="material"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.material.label")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
</div>
</div>
</div>
</ProgressTabs.Content>
<ProgressTabs.Content
value={Tab.AVAILABILITY}
className="h-full w-full"
style={{ width: "100vw" }}
>
<CreateInventoryAvailabilityForm form={form} />
</ProgressTabs.Content>
</RouteFocusModal.Body>
</ProgressTabs>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./create-inventory-item-form.tsx"

View File

@@ -0,0 +1 @@
export { InventoryCreate as Component } from "./inventory-create"

View File

@@ -0,0 +1,10 @@
import { RouteFocusModal } from "../../../components/route-modal"
import { CreateInventoryItemForm } from "./components/create-inventory-item-form"
export function InventoryCreate() {
return (
<RouteFocusModal>
<CreateInventoryItemForm />
</RouteFocusModal>
)
}

View File

@@ -1,22 +1,20 @@
import * as zod from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Input, Text, toast } from "@medusajs/ui"
import { InventoryLevelDTO, StockLocationDTO } from "@medusajs/types"
import { InventoryLevelDTO, StockLocationDTO, HttpTypes } from "@medusajs/types"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../../components/route-modal"
import { Form } from "../../../../../../components/common/form"
import { InventoryItemRes } from "../../../../../../types/api-responses"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useUpdateInventoryItemLevel } from "../../../../../../hooks/api/inventory"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useUpdateInventoryLevel } from "../../../../../../hooks/api/inventory"
type AdjustInventoryFormProps = {
item: InventoryItemRes["inventory_item"]
item: HttpTypes.AdminInventoryItem
level: InventoryLevelDTO
location: StockLocationDTO
}
@@ -48,8 +46,8 @@ export const AdjustInventoryForm = ({
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const AdjustInventorySchema = z.object({
stocked_quantity: z.number().min(level.reserved_quantity),
const AdjustInventorySchema = zod.object({
stocked_quantity: zod.number().min(level.reserved_quantity),
})
const form = useForm<zod.infer<typeof AdjustInventorySchema>>({
@@ -61,7 +59,7 @@ export const AdjustInventoryForm = ({
const stockedQuantityUpdate = form.watch("stocked_quantity")
const { mutateAsync, isPending: isLoading } = useUpdateInventoryItemLevel(
const { mutateAsync, isPending: isLoading } = useUpdateInventoryLevel(
item.id,
level.location_id
)

View File

@@ -5,7 +5,7 @@ import {
RouteDrawer,
useRouteModal,
} from "../../../../../../components/route-modal"
import { useBatchInventoryItemLevels } from "../../../../../../hooks/api/inventory"
import { useBatchUpdateInventoryLevels } from "../../../../../../hooks/api/inventory"
import { useFieldArray, useForm } from "react-hook-form"
import { InventoryItemRes } from "../../../../../../types/api-responses"
@@ -64,7 +64,7 @@ export const ManageLocationsForm = ({
name: "locations",
})
const { mutateAsync } = useBatchInventoryItemLevels(item.id)
const { mutateAsync } = useBatchUpdateInventoryLevels(item.id)
const handleSubmit = form.handleSubmit(async ({ locations }) => {
// Changes in selected locations
@@ -96,10 +96,10 @@ export const ManageLocationsForm = ({
try {
await mutateAsync({
creates: selectedLocations.map((location_id) => ({
create: selectedLocations.map((location_id) => ({
location_id,
})),
deletes: unselectedLocations,
delete: unselectedLocations,
})
handleSuccess()

View File

@@ -33,7 +33,6 @@ export const InventoryDetail = () => {
if (isLoading) {
return <div>Loading...</div>
}
if (isError || !inventory_item) {
if (error) {
throw error

View File

@@ -1,13 +1,14 @@
import { LoaderFunctionArgs } from "react-router-dom"
import { HttpTypes } from "@medusajs/types"
import { inventoryItemsQueryKeys } from "../../../hooks/api/inventory"
import { client } from "../../../lib/client"
import { sdk } from "../../../lib/client"
import { queryClient } from "../../../lib/query-client"
import { InventoryItemRes } from "../../../types/api-responses"
const inventoryDetailQuery = (id: string) => ({
queryKey: inventoryItemsQueryKeys.detail(id),
queryFn: async () =>
client.inventoryItems.retrieve(id, {
sdk.admin.inventoryItem.retrieve(id, {
fields: "*variant",
}),
})
@@ -17,7 +18,8 @@ export const inventoryItemLoader = async ({ params }: LoaderFunctionArgs) => {
const query = inventoryDetailQuery(id!)
return (
queryClient.getQueryData<InventoryItemRes>(query.queryKey) ??
(await queryClient.fetchQuery(query))
queryClient.getQueryData<HttpTypes.AdminInventoryItemResponse>(
query.queryKey
) ?? (await queryClient.fetchQuery(query))
)
}

View File

@@ -1,4 +1,4 @@
import { Container, Heading } from "@medusajs/ui"
import { Button, Container, Heading } from "@medusajs/ui"
import { InventoryNext } from "@medusajs/types"
import { DataTable } from "../../../../components/table/data-table"
@@ -8,6 +8,7 @@ import { useInventoryTableColumns } from "./use-inventory-table-columns"
import { useInventoryTableFilters } from "./use-inventory-table-filters"
import { useInventoryTableQuery } from "./use-inventory-table-query"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
const PAGE_SIZE = 20
@@ -48,6 +49,9 @@ export const InventoryListTable = () => {
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("inventory.domain")}</Heading>
<Button size="small" variant="secondary" asChild>
<Link to="create">{t("actions.create")}</Link>
</Button>
</div>
<DataTable
table={table}

View File

@@ -110,27 +110,9 @@ export type BatchUpdatePromotionRulesReq = { rules: UpdatePromotionRuleDTO[] }
export type CreateCampaignReq = CreateCampaignDTO
export type UpdateCampaignReq = UpdateCampaignDTO
// Inventory Items
export type CreateInventoryItemReq = InventoryNext.CreateInventoryItemInput
export type UpdateInventoryItemReq = Omit<
InventoryNext.UpdateInventoryItemInput,
"id"
>
// Reservations
export type UpdateReservationReq = Omit<
InventoryNext.UpdateReservationItemInput,
"id"
>
export type CreateReservationReq = InventoryNext.CreateReservationItemInput
// Inventory Item Levels
export type InventoryItemLocationBatch = {
creates: { location_id: string; stocked_quantity?: number }[]
deletes: string[]
}
export type UpdateInventoryLevelReq = {
reserved_quantity?: number
stocked_quantity?: number
}

View File

@@ -176,27 +176,6 @@ export type WorkflowExecutionListRes = {
export type TaxRegionDeleteRes = DeleteRes
export type TaxRateDeleteRes = DeleteRes
// Inventory Items
export type InventoryItemRes = {
inventory_item: InventoryNext.InventoryItemDTO & {
stocked_quantity: number
reserved_quantity: number
location_levels?: InventoryNext.InventoryLevelDTO[]
variant?: ProductVariantDTO | ProductVariantDTO[]
}
}
export type InventoryItemListRes = {
inventory_items: InventoryNext.InventoryItemDTO[]
} & ListRes
export type InventoryItemDeleteRes = DeleteRes
export type InventoryItemLocationLevelsRes = {
inventory_levels: InventoryNext.InventoryLevelDTO[]
} & ListRes
export type InventoryItemLevelDeleteRes = DeleteRes
// Reservations
export type ReservationItemDeleteRes = DeleteRes

View File

@@ -39,14 +39,9 @@ export const deleteInventoryLevelsFromItemAndLocationsStep = createStep(
}
const deletedIds = items.map((i) => i.id)
const deleted = await service.softDeleteInventoryLevels(deletedIds)
await service.softDeleteInventoryLevels(deletedIds)
return new StepResponse(
{
[Modules.INVENTORY]: deleted,
} as DeleteEntityInput,
deletedIds
)
return new StepResponse(void 0, deletedIds)
},
async (prevLevelIds, { container }) => {
if (!prevLevelIds?.length) {

View File

@@ -5,8 +5,6 @@ import {
deleteInventoryLevelsFromItemAndLocationsStep,
} from "../steps"
import { removeRemoteLinkStep } from "../../common"
interface WorkflowInput {
creates: InventoryNext.CreateInventoryLevelInput[]
deletes: { inventory_item_id: string; location_id: string }[]
@@ -17,9 +15,7 @@ export const bulkCreateDeleteLevelsWorkflowId =
export const bulkCreateDeleteLevelsWorkflow = createWorkflow(
bulkCreateDeleteLevelsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<InventoryLevelDTO[]> => {
const deleted = deleteInventoryLevelsFromItemAndLocationsStep(input.deletes)
removeRemoteLinkStep(deleted)
deleteInventoryLevelsFromItemAndLocationsStep(input.deletes)
return createInventoryLevelsStep(input.creates)
}

View File

@@ -14,6 +14,7 @@ import { StockLocation } from "./stock-location"
import { TaxRate } from "./tax-rate"
import { TaxRegion } from "./tax-region"
import { Upload } from "./upload"
import { InventoryItem } from "./inventory-item"
export class Admin {
public invite: Invite
@@ -28,6 +29,7 @@ export class Admin {
public fulfillment: Fulfillment
public shippingOption: ShippingOption
public shippingProfile: ShippingProfile
public inventoryItem: InventoryItem
public order: Order
public taxRate: TaxRate
public taxRegion: TaxRegion
@@ -45,6 +47,7 @@ export class Admin {
this.fulfillment = new Fulfillment(client)
this.shippingOption = new ShippingOption(client)
this.shippingProfile = new ShippingProfile(client)
this.inventoryItem = new InventoryItem(client)
this.order = new Order(client)
this.taxRate = new TaxRate(client)
this.taxRegion = new TaxRegion(client)

View File

@@ -0,0 +1,135 @@
import { HttpTypes, SelectParams } from "@medusajs/types"
import { Client } from "../client"
import { ClientHeaders } from "../types"
export class InventoryItem {
private client: Client
constructor(client: Client) {
this.client = client
}
async create(
body: HttpTypes.AdminCreateInventoryItem,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminInventoryItemResponse>(
`/admin/inventory-items`,
{
method: "POST",
headers,
body,
query,
}
)
}
async update(
id: string,
body: HttpTypes.AdminUpdateInventoryItem,
query?: SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminInventoryItemResponse>(
`/admin/inventory-items/${id}`,
{
method: "POST",
headers,
body,
query,
}
)
}
async list(
query?: HttpTypes.AdminInventoryItemParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminInventoryItemListResponse>(
`/admin/inventory-items`,
{
query,
headers,
}
)
}
async retrieve(id: string, query?: SelectParams, headers?: ClientHeaders) {
return await this.client.fetch<HttpTypes.AdminInventoryItemResponse>(
`/admin/inventory-items/${id}`,
{
query,
headers,
}
)
}
async delete(id: string, headers?: ClientHeaders) {
return await this.client.fetch<HttpTypes.AdminInventoryItemDeleteResponse>(
`/admin/inventory-items/${id}`,
{
method: "DELETE",
headers,
}
)
}
async listLevels(
id: string,
query?: HttpTypes.AdminInventoryLevelFilters,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminInventoryLevelListResponse>(
`/admin/inventory-items/${id}/location-levels`,
{
query,
headers,
}
)
}
async updateLevel(
id: string,
locationId: string,
body: HttpTypes.AdminUpdateInventoryLevel,
query?: SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminInventoryItemResponse>(
`/admin/inventory-items/${id}/location-levels/${locationId}`,
{
method: "POST",
headers,
body,
query,
}
)
}
async deleteLevel(id: string, locationId: string, headers?: ClientHeaders) {
return await this.client.fetch<HttpTypes.AdminInventoryItemDeleteResponse>(
`/admin/inventory-items/${id}/location-levels/${locationId}`,
{
method: "DELETE",
headers,
}
)
}
async batchUpdateLevels(
id: string,
body: HttpTypes.AdminBatchUpdateInventoryLevelLocation,
query?: SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminInventoryItemResponse>(
`/admin/inventory-items/${id}/location-levels/batch`,
{
method: "POST",
headers,
body,
query,
}
)
}
}

View File

@@ -9,6 +9,7 @@ export * from "./fulfillment"
export * from "./fulfillment-provider"
export * from "./fulfillment-set"
export * from "./inventory"
export * from "./inventory-level"
export * from "./invite"
export * from "./order"
export * from "./payment"

View File

@@ -0,0 +1,10 @@
export interface InventoryLevel {
id: string
inventory_item_id: string
location_id: string
stocked_quantity: number
reserved_quantity: number
available_quantity: number
incoming_quantity: number
metadata?: Record<string, unknown> | null
}

View File

@@ -0,0 +1,4 @@
export * from "./entities"
export * from "./payloads"
export * from "./queries"
export * from "./responses"

View File

@@ -0,0 +1,16 @@
export interface AdminUpdateInventoryLevel {
stocked_quantity?: number
incoming_quantity?: number
}
export interface AdminCreateInventoryLevel {
location_id: string
stocked_quantity?: number
incoming_quantity?: number
}
export interface AdminBatchUpdateInventoryLevelLocation {
delete?: string[] // a list of location_ids
update?: never // TODO - not implemented // AdminUpdateInventoryLevel[]
create?: AdminCreateInventoryLevel[]
}

View File

@@ -0,0 +1,5 @@
import { FindParams } from "../../common"
export interface AdminInventoryLevelFilters extends FindParams {
location_id?: string | string[]
}

View File

@@ -0,0 +1,10 @@
import { PaginatedResponse } from "../../common"
import { InventoryLevel } from "./entities"
export interface AdminInventoryLevelResponse {
inventory_level: InventoryLevel
}
export type AdminInventoryLevelListResponse = PaginatedResponse<{
inventory_levels: InventoryLevel[]
}>

View File

@@ -0,0 +1 @@
export * from "./admin"

View File

@@ -1,6 +1,4 @@
import { PaginatedResponse } from "../common"
interface InventoryItemResponse {
export interface AdminInventoryItem {
id: string
sku?: string | null
origin_country?: string | null
@@ -17,11 +15,3 @@ interface InventoryItemResponse {
thumbnail?: string | null
metadata?: Record<string, unknown> | null
}
export interface AdminInventoryItemResponse {
inventory_item: InventoryItemResponse
}
export type AdminInventoryItemListResponse = PaginatedResponse<{
inventory_items: InventoryItemResponse[]
}>

View File

@@ -0,0 +1,4 @@
export * from "./entities"
export * from "./payloads"
export * from "./queries"
export * from "./responses"

View File

@@ -0,0 +1,18 @@
export interface AdminCreateInventoryItem {
sku?: string
hs_code?: string
weight?: number
length?: number
height?: number
width?: number
origin_country?: string
mid_code?: string
material?: string
title?: string
description?: string
requires_shipping?: boolean
thumbnail?: string
metadata?: Record<string, unknown>
}
export interface AdminUpdateInventoryItem extends AdminCreateInventoryItem {}

View File

@@ -0,0 +1,17 @@
import { FindParams } from "../../common"
import { OperatorMap } from "../../../dal"
export interface AdminInventoryItemParams extends FindParams {
id?: string | string[]
q?: string
sku?: string | string[]
origin_country?: string | string[]
hs_code?: string | string[]
material?: string | string[]
requires_shipping?: boolean
weight?: number | OperatorMap<number>
length?: number | OperatorMap<number>
height?: number | OperatorMap<number>
width?: number | OperatorMap<number>
location_levels?: Record<"location_id", string | string[]>
}

View File

@@ -0,0 +1,12 @@
import { DeleteResponse, PaginatedResponse } from "../../common"
import { AdminInventoryItem } from "./entities"
export interface AdminInventoryItemResponse {
inventory_item: AdminInventoryItem
}
export type AdminInventoryItemListResponse = PaginatedResponse<{
inventory_items: AdminInventoryItem[]
}>
export type AdminInventoryItemDeleteResponse = DeleteResponse<"inventory_item">

View File

@@ -1,2 +1 @@
export * from "./inventory"
export * from "./inventory-level"
export * from "./admin"

View File

@@ -1,21 +0,0 @@
import { AdminInventoryItemResponse } from "./inventory"
import { PaginatedResponse } from "../common"
interface InventoryLevelResponse {
id: string
inventory_item_id: string
location_id: string
stocked_quantity: number
reserved_quantity: number
available_quantity: number
incoming_quantity: number
metadata?: Record<string, unknown> | null
}
export interface AdminInventoryLevelResponse {
inventory_item: AdminInventoryItemResponse
}
export type AdminInventoryLevelListResponse = PaginatedResponse<{
inventory_levels: InventoryLevelResponse[]
}>

View File

@@ -41,12 +41,14 @@ export const GET = async (
req: MedusaRequest<AdminGetInventoryLocationLevelsParamsType>,
res: MedusaResponse
) => {
const { id } = req.params
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const query = remoteQueryObjectFromString({
entryPoint: "inventory_levels",
variables: {
filters: req.filterableFields,
filters: { ...req.filterableFields, inventory_item_id: id },
...req.remoteQueryConfig.pagination,
},
fields: req.remoteQueryConfig.fields,