Feat(admin-next, core-flows, link-modules, medusa, types): Inventory end to end flows (#7020)

* add reservation endpoints

* add changeset

* initial

* add reservations table

* add edit-item modal

* udpate inventory item attributes

* manage locations skeleton

* add combi batch endpoint

* cleanup

* fix manage locations

* add adjust inventory

* prep for pr

* update versions

* fix for pr

* fix for pr

* cleanup

* Update packages/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>

* Update packages/core-flows/src/inventory/steps/delete-levels-by-item-and-location.ts

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>

* rm wack import

* fix build

---------

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
Philip Korsholm
2024-04-10 15:17:54 +02:00
committed by GitHub
parent 276278cbe7
commit ab7ff64c4a
66 changed files with 2894 additions and 43 deletions

View File

@@ -30,6 +30,7 @@
"items_one": "{{count}} item",
"items_other": "{{count}} items",
"countSelected": "{{count}} selected",
"countOfTotalSelected": "{{count}} of {{total}} selected",
"plusCount": "+ {{count}}",
"plusCountMore": "+ {{count}} more",
"areYouSure": "Are you sure?",
@@ -265,6 +266,7 @@
},
"inventory": {
"header": "Stock & Inventory",
"editItemDetails": "Edit item details",
"manageInventoryLabel": "Manage inventory",
"manageInventoryHint": "When enabled the inventory level will be regulated when orders and returns are created.",
"allowBackordersLabel": "Allow backorders",
@@ -311,6 +313,10 @@
"inventory": {
"domain": "Inventory",
"reserved": "Reserved",
"available": "Available",
"locationLevels": "Location levels",
"associatedVariants": "Associated variants",
"manageLocations": "Manage locations",
"deleteWarning": "You are about to delete an inventory item. This action cannot be undone."
},
"giftCards": {
@@ -943,6 +949,7 @@
"addSalesChannels": "Add sales channels",
"detailsHint": "Specify the details of the location.",
"noLocationsFound": "No locations found",
"selectLocations": "Select locations that stock the item.",
"deleteLocationWarning": "You are about to delete the location {{name}}. This action cannot be undone.",
"removeSalesChannelsWarning_one": "You are about to remove {{count}} sales channel from the location.",
"removeSalesChannelsWarning_other": "You are about to remove {{count}} sales channels from the location."

View File

@@ -1,7 +1,8 @@
import { EllipsisHorizontal } from "@medusajs/icons"
import { DropdownMenu, IconButton } from "@medusajs/ui"
import { ReactNode } from "react"
import { EllipsisHorizontal } from "@medusajs/icons"
import { Link } from "react-router-dom"
import { ReactNode } from "react"
type Action = {
icon: ReactNode

View File

@@ -0,0 +1,291 @@
import {
InventoryItemDeleteRes,
InventoryItemListRes,
InventoryItemLocationLevelsRes,
InventoryItemRes,
ReservationItemDeleteRes,
ReservationItemListRes,
ReservationItemRes,
} from "../../types/api-responses"
import {
InventoryItemLocationBatch,
UpdateInventoryItemReq,
UpdateInventoryLevelReq,
} from "../../types/api-payloads"
import {
QueryKey,
UseMutationOptions,
UseQueryOptions,
useMutation,
useQuery,
} from "@tanstack/react-query"
import { InventoryNext } from "@medusajs/types"
import { client } from "../../lib/client"
import { queryClient } from "../../lib/medusa"
import { queryKeysFactory } from "../../lib/query-key-factory"
const INVENTORY_ITEMS_QUERY_KEY = "inventory_items" as const
export const inventoryItemsQueryKeys = queryKeysFactory(
INVENTORY_ITEMS_QUERY_KEY
)
const INVENTORY_ITEM_LEVELS_QUERY_KEY = "inventory_item_levels" as const
export const inventoryItemLevelsQueryKeys = queryKeysFactory(
INVENTORY_ITEM_LEVELS_QUERY_KEY
)
const RESERVATION_ITEMS_QUERY_KEY = "reservation_items" as const
export const reservationItemsQueryKeys = queryKeysFactory(
RESERVATION_ITEMS_QUERY_KEY
)
export const useInventoryItems = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<
InventoryItemListRes,
Error,
InventoryItemListRes,
QueryKey
>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => client.inventoryItems.list(query),
queryKey: inventoryItemsQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}
export const useInventoryItem = (
id: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<InventoryItemRes, Error, InventoryItemRes, QueryKey>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => client.inventoryItems.retrieve(id, query),
queryKey: inventoryItemsQueryKeys.detail(id),
...options,
})
return { ...data, ...rest }
}
export const useUpdateInventoryItem = (
id: string,
options?: UseMutationOptions<InventoryItemRes, Error, UpdateInventoryItemReq>
) => {
return useMutation({
mutationFn: (payload: InventoryNext.UpdateInventoryItemInput) =>
client.inventoryItems.update(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.detail(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteInventoryItem = (
id: string,
options?: UseMutationOptions<InventoryItemDeleteRes, Error, void>
) => {
return useMutation({
mutationFn: () => client.inventoryItems.delete(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.detail(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteInventoryItemLevel = (
inventoryItemId: string,
locationId: string,
options?: UseMutationOptions<InventoryItemDeleteRes, Error, void>
) => {
return useMutation({
mutationFn: () =>
client.inventoryItems.deleteInventoryItemLevel(
inventoryItemId,
locationId
),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.detail(inventoryItemId),
})
queryClient.invalidateQueries({
queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useInventoryItemLevels = (
inventoryItemId: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<
InventoryItemLocationLevelsRes,
Error,
InventoryItemLocationLevelsRes,
QueryKey
>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () =>
client.inventoryItems.listLocationLevels(inventoryItemId, query),
queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId),
...options,
})
return { ...data, ...rest }
}
export const useUpdateInventoryItemLevel = (
inventoryItemId: string,
locationId: string,
options?: UseMutationOptions<
AdminInventoryLevelResponse,
Error,
UpdateInventoryLevelReq
>
) => {
return useMutation({
mutationFn: (payload: UpdateInventoryLevelReq) =>
client.inventoryItems.updateInventoryLevel(
inventoryItemId,
locationId,
payload
),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.detail(inventoryItemId),
})
queryClient.invalidateQueries({
queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useBatchInventoryItemLevels = (
inventoryItemId: string,
options?: UseMutationOptions<
InventoryItemLocationLevelsRes,
Error,
InventoryItemLocationBatch
>
) => {
return useMutation({
mutationFn: (payload: InventoryItemLocationBatch) =>
client.inventoryItems.batchPostLocationLevels(inventoryItemId, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.detail(inventoryItemId),
})
queryClient.invalidateQueries({
queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useReservationItems = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<
ReservationItemListRes,
Error,
ReservationItemListRes,
QueryKey
>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => client.inventoryItems.listReservationItems(query),
queryKey: reservationItemsQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}
export const useUpdateReservationItem = (
id: string,
payload: InventoryNext.UpdateInventoryItemInput,
options?: UseMutationOptions<
ReservationItemRes,
Error,
UpdateInventoryItemReq
>
) => {
return useMutation({
mutationFn: () => client.inventoryItems.updateReservationItem(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.detail(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteReservationItem = (
id: string,
options?: UseMutationOptions<ReservationItemDeleteRes, Error, void>
) => {
return useMutation({
mutationFn: () => client.inventoryItems.deleteReservationItem(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.detail(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}

View File

@@ -6,6 +6,7 @@ import { collections } from "./collections"
import { currencies } from "./currencies"
import { customerGroups } from "./customer-groups"
import { customers } from "./customers"
import { inventoryItems } from "./inventory"
import { invites } from "./invites"
import { payments } from "./payments"
import { priceLists } from "./price-lists"
@@ -39,6 +40,7 @@ export const client = {
regions: regions,
taxes: taxes,
invites: invites,
inventoryItems: inventoryItems,
products: products,
productTypes: productTypes,
priceLists: priceLists,

View File

@@ -0,0 +1,131 @@
import {
AdminInventoryItemListResponse,
AdminInventoryItemResponse,
AdminInventoryLevelListResponse,
AdminInventoryLevelResponse,
} from "@medusajs/types"
import {
CreateInventoryItemReq,
InventoryItemLocationBatch,
UpdateInventoryItemReq,
UpdateInventoryLevelReq,
} from "../../types/api-payloads"
import {
InventoryItemLevelDeleteRes,
ReservationItemDeleteRes,
ReservationItemListRes,
ReservationItemRes,
} 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<AdminInventoryLevelListResponse>(
`/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<AdminInventoryLevelResponse>(
`/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}`,
payload
)
}
async function listReservationItems(query?: Record<string, any>) {
return getRequest<ReservationItemListRes>(`/admin/reservations`, query)
}
async function deleteReservationItem(reservationId: string) {
return deleteRequest<ReservationItemDeleteRes>(
`/admin/reservations/${reservationId}`
)
}
async function updateReservationItem(
reservationId: string,
payload: UpdateInventoryItemReq
) {
return postRequest<ReservationItemRes>(
`/admin/reservatinos/${reservationId}`,
payload
)
}
async function batchPostLocationLevels(
inventoryItemId: string,
payload: InventoryItemLocationBatch
) {
return postRequest<AdminInventoryLevelResponse>(
`/admin/inventory-items/${inventoryItemId}/location-levels/batch/combi`,
payload
)
}
export const inventoryItems = {
retrieve: retrieveInventoryItem,
list: listInventoryItems,
create: createInventoryItem,
update: updateInventoryItem,
delete: deleteInventoryItem,
listLocationLevels: listInventoryItemLevels,
updateInventoryLevel,
deleteInventoryItemLevel,
listReservationItems,
deleteReservationItem,
updateReservationItem,
batchPostLocationLevels,
}

View File

@@ -1,11 +1,3 @@
import { AdminCustomersRes } from "@medusajs/client-types"
import { Spinner } from "@medusajs/icons"
import {
AdminCollectionsRes,
AdminProductsRes,
AdminPromotionRes,
AdminRegionsRes,
} from "@medusajs/medusa"
import {
AdminApiKeyResponse,
AdminCustomerGroupResponse,
@@ -13,14 +5,24 @@ import {
SalesChannelDTO,
UserDTO,
} from "@medusajs/types"
import {
AdminCollectionsRes,
AdminProductsRes,
AdminPromotionRes,
AdminRegionsRes,
} from "@medusajs/medusa"
import { Navigate, Outlet, RouteObject, useLocation } from "react-router-dom"
import { AdminCustomersRes } from "@medusajs/client-types"
import { ErrorBoundary } from "../../components/error/error-boundary"
import { InventoryItemRes } from "../../types/api-responses"
import { MainLayout } from "../../components/layout-v2/main-layout"
import { SettingsLayout } from "../../components/layout/settings-layout"
import { useMe } from "../../hooks/api/users"
import { PriceListRes } from "../../types/api-responses"
import { SearchProvider } from "../search-provider"
import { SettingsLayout } from "../../components/layout/settings-layout"
import { SidebarProvider } from "../sidebar-provider"
import { Spinner } from "@medusajs/icons"
import { useMe } from "../../hooks/api/users"
export const ProtectedRoute = () => {
const { user, isLoading } = useMe()
@@ -393,6 +395,73 @@ export const v2Routes: RouteObject[] = [
},
],
},
{
path: "/inventory",
handle: {
crumb: () => "Inventory",
},
children: [
{
path: "",
lazy: () => import("../../v2-routes/inventory/inventory-list"),
},
{
path: ":id",
lazy: () =>
import("../../v2-routes/inventory/inventory-detail"),
handle: {
crumb: (data: InventoryItemRes) =>
data.inventory_item.title ?? data.inventory_item.sku,
},
children: [
{
// TODO: edit item
path: "edit",
lazy: () =>
import(
"../../v2-routes/inventory/inventory-detail/components/edit-inventory-item"
),
},
{
// TODO: edit item attributes
path: "attributes",
lazy: () =>
import(
"../../v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes"
),
},
{
// TODO: manage locations
path: "locations",
lazy: () =>
import(
"../../v2-routes/inventory/inventory-detail/components/manage-locations"
),
},
{
// TODO: adjust item level
path: "locations/:location_id",
lazy: () =>
import(
"../../v2-routes/inventory/inventory-detail/components/adjust-inventory"
),
},
{
// TODO: create reservation
path: "reservations",
lazy: () =>
import("../../v2-routes/customers/customer-edit"),
},
{
// TODO: edit reservation
path: "reservations/:reservation_id",
lazy: () =>
import("../../v2-routes/customers/customer-edit"),
},
],
},
],
},
],
},
],

View File

@@ -1,12 +1,11 @@
import { useTranslation } from "react-i18next"
import { PencilSquare } from "@medusajs/icons"
import type { Discount } from "@medusajs/medusa"
import { Container, Copy, Heading, Text } from "@medusajs/ui"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
import type { Discount } from "@medusajs/medusa"
import { ListSummary } from "../../../../../components/common/list-summary"
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
import { PencilSquare } from "@medusajs/icons"
import { useTranslation } from "react-i18next"
export const DetailsSection = ({ discount }: { discount: Discount }) => {
const { t } = useTranslation()
@@ -54,8 +53,8 @@ export const DetailsSection = ({ discount }: { discount: Discount }) => {
{discount.rule.type === "percentage"
? t("discounts.percentageDiscount")
: discount.rule.type === "free_shipping"
? t("discounts.freeShipping")
: t("discounts.fixedDiscount")}
? t("discounts.freeShipping")
: t("discounts.fixedDiscount")}
</Text>
</div>

View File

@@ -14,6 +14,7 @@ import {
CreateRegionDTO,
CreateSalesChannelDTO,
CreateStockLocationInput,
InventoryNext,
UpdateApiKeyDTO,
UpdateCampaignDTO,
UpdateCustomerDTO,
@@ -88,3 +89,21 @@ export type BatchUpdatePromotionRulesReq = { rules: UpdatePromotionRuleDTO[] }
// Campaign
export type CreateCampaignReq = CreateCampaignDTO
export type UpdateCampaignReq = UpdateCampaignDTO
// Inventory Items
export type CreateInventoryItemReq = InventoryNext.CreateInventoryItemInput
export type UpdateInventoryItemReq = Omit<
InventoryNext.UpdateInventoryItemInput,
"id"
>
// 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

@@ -6,6 +6,7 @@ import {
CampaignDTO,
CurrencyDTO,
CustomerGroupDTO,
InventoryNext,
InviteDTO,
PaymentProviderDTO,
PriceListDTO,
@@ -22,6 +23,7 @@ import {
StoreDTO,
UserDTO,
} from "@medusajs/types"
import { ProductTagDTO } from "@medusajs/types/dist/product"
import { WorkflowExecutionDTO } from "../v2-routes/workflow-executions/types"
@@ -140,6 +142,36 @@ export type ProductCollectionListRes = {
} & ListRes
export type ProductCollectionDeleteRes = DeleteRes
// Inventory Items
export type InventoryItemRes = {
inventory_item: InventoryNext.InventoryItemDTO & {
stocked_quantity: number
reserved_quantity: number
location_levels?: InventoryNext.InventoryLevelDTO[]
}
}
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
export type ReservationItemListRes = {
reservations: InventoryNext.ReservationItemDTO[]
} & ListRes
export type ReservationItemRes = {
reservation: InventoryNext.ReservationItemDTO
}
// Price Lists
export type PriceListRes = { price_list: PriceListDTO }
export type PriceListListRes = { price_lists: PriceListDTO[] } & ListRes

View File

@@ -0,0 +1,55 @@
import { AdjustInventoryForm } from "./components/adjust-inventory-form"
import { Heading } from "@medusajs/ui"
import { InventoryNext } from "@medusajs/types"
import { RouteDrawer } from "../../../../../components/route-modal"
import { useInventoryItem } from "../../../../../hooks/api/inventory"
import { useParams } from "react-router-dom"
import { useStockLocation } from "../../../../../hooks/api/stock-locations"
import { useTranslation } from "react-i18next"
export const AdjustInventoryDrawer = () => {
const { id, location_id } = useParams()
const { t } = useTranslation()
const {
inventory_item: inventoryItem,
isLoading,
isError,
error,
} = useInventoryItem(id!)
const inventoryLevel = inventoryItem?.location_levels!.find(
(level: InventoryNext.InventoryLevelDTO) =>
level.location_id === location_id
)
const { stock_location, isLoading: isLoadingLocation } = useStockLocation(
location_id!
)
const ready =
!isLoading &&
inventoryItem &&
inventoryLevel &&
!isLoadingLocation &&
stock_location
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("inventory.manageLocations")}</Heading>
</RouteDrawer.Header>
{ready && (
<AdjustInventoryForm
item={inventoryItem}
level={inventoryLevel}
location={stock_location}
/>
)}
</RouteDrawer>
)
}

View File

@@ -0,0 +1,152 @@
import * as zod from "zod"
import { Button, Input, Text } from "@medusajs/ui"
import { InventoryLevelDTO, StockLocationDTO } 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"
type AdjustInventoryFormProps = {
item: InventoryItemRes["inventory_item"]
level: InventoryLevelDTO
location: StockLocationDTO
}
const AttributeGridRow = ({
title,
value,
}: {
title: string
value: string | number
}) => {
return (
<div className="grid grid-cols-2 divide-x">
<Text className="px-2 py-1.5" size="small" leading="compact">
{title}
</Text>
<Text className="px-2 py-1.5" size="small" leading="compact">
{value}
</Text>
</div>
)
}
export const AdjustInventoryForm = ({
item,
level,
location,
}: AdjustInventoryFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const AdjustInventorySchema = z.object({
stocked_quantity: z.number().min(level.reserved_quantity),
})
const form = useForm<zod.infer<typeof AdjustInventorySchema>>({
defaultValues: {
stocked_quantity: level.stocked_quantity,
},
resolver: zodResolver(AdjustInventorySchema),
})
const stockedQuantityUpdate = form.watch("stocked_quantity")
const { mutateAsync } = useUpdateInventoryItemLevel(
item.id,
level.location_id
)
const handleSubmit = form.handleSubmit(async (value) => {
if (value.stocked_quantity === level.stocked_quantity) {
return handleSuccess()
}
await mutateAsync({
stocked_quantity: value.stocked_quantity,
})
return handleSuccess()
})
return (
<RouteDrawer.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-8 overflow-auto">
<div className="text-ui-fg-subtle shadow-elevation-card-rest grid grid-rows-4 divide-y rounded-lg border">
<AttributeGridRow
title={t("fields.title")}
value={item.title ?? "-"}
/>
<AttributeGridRow title={t("fields.sku")} value={item.sku!} />
<AttributeGridRow
title={t("locations.domain")}
value={location.name}
/>
<AttributeGridRow
title={t("inventory.reserved")}
value={item.reserved_quantity}
/>
<AttributeGridRow
title={t("inventory.available")}
value={stockedQuantityUpdate - item.reserved_quantity}
/>
</div>
<Form.Field
control={form.control}
name="stocked_quantity"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.inStock")}</Form.Label>
<Form.Control>
<Input
type="number"
min={level.reserved_quantity}
value={value || ""}
onChange={(e) => {
const value = e.target.value
if (value === "") {
onChange(null)
} else {
onChange(parseFloat(value))
}
}}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button type="submit" size="small" isLoading={false}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}

View File

@@ -0,0 +1 @@
export { AdjustInventoryDrawer as Component } from "./adjust-inventory-drawer"

View File

@@ -0,0 +1,252 @@
import * as zod from "zod"
import { Button, Input } from "@medusajs/ui"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../../components/route-modal"
import { CountrySelect } from "../../../../../../components/common/country-select"
import { Form } from "../../../../../../components/common/form"
import { InventoryNext } from "@medusajs/types"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useUpdateInventoryItem } from "../../../../../../hooks/api/inventory"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
type EditInventoryItemAttributeFormProps = {
item: InventoryNext.InventoryItemDTO
}
const EditInventoryItemAttributesSchema = z.object({
height: z.number().positive().optional(),
width: z.number().positive().optional(),
length: z.number().positive().optional(),
weight: z.number().positive().optional(),
mid_code: z.string().optional(),
hs_code: z.string().optional(),
origin_country: z.string().optional(),
})
const getDefaultValues = (item: InventoryNext.InventoryItemDTO) => {
return {
height: item.height ?? undefined,
width: item.width ?? undefined,
length: item.length ?? undefined,
weight: item.weight ?? undefined,
mid_code: item.mid_code ?? undefined,
hs_code: item.hs_code ?? undefined,
origin_country: item.origin_country ?? undefined,
}
}
export const EditInventoryItemAttributesForm = ({
item,
}: EditInventoryItemAttributeFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<zod.infer<typeof EditInventoryItemAttributesSchema>>({
defaultValues: getDefaultValues(item),
resolver: zodResolver(EditInventoryItemAttributesSchema),
})
const { mutateAsync } = useUpdateInventoryItem(item.id)
const handleSubmit = form.handleSubmit(async (values) => {
mutateAsync(values, {
onSuccess: () => {
handleSuccess()
},
})
})
return (
<RouteDrawer.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-4 overflow-auto">
<Form.Field
control={form.control}
name="height"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.height")}</Form.Label>
<Form.Control>
<Input
type="number"
min={0}
value={value || ""}
onChange={(e) => {
const value = e.target.value
if (value === "") {
onChange(null)
} else {
onChange(parseFloat(value))
}
}}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="width"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.width")}</Form.Label>
<Form.Control>
<Input
type="number"
min={0}
value={value || ""}
onChange={(e) => {
const value = e.target.value
if (value === "") {
onChange(null)
} else {
onChange(parseFloat(value))
}
}}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="length"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.length")}</Form.Label>
<Form.Control>
<Input
type="number"
min={0}
value={value || ""}
onChange={(e) => {
const value = e.target.value
if (value === "") {
onChange(null)
} else {
onChange(parseFloat(value))
}
}}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="weight"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.weight")}</Form.Label>
<Form.Control>
<Input
type="number"
min={0}
value={value || ""}
onChange={(e) => {
const value = e.target.value
if (value === "") {
onChange(null)
} else {
onChange(parseFloat(value))
}
}}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="mid_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.midCode")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="hs_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.hsCode")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="origin_country"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("fields.countryOfOrigin")}
</Form.Label>
<Form.Control>
<CountrySelect {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button type="submit" size="small" isLoading={false}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}

View File

@@ -0,0 +1,33 @@
import { EditInventoryItemAttributesForm } from "./components/edit-item-attributes-form"
import { Heading } from "@medusajs/ui"
import { RouteDrawer } from "../../../../../components/route-modal"
import { useInventoryItem } from "../../../../../hooks/api/inventory"
import { useParams } from "react-router-dom"
import { useTranslation } from "react-i18next"
export const InventoryItemAttributesEdit = () => {
const { id } = useParams()
const { t } = useTranslation()
const {
inventory_item: inventoryItem,
isLoading,
isError,
error,
} = useInventoryItem(id!)
const ready = !isLoading && inventoryItem
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("products.editAttributes")}</Heading>
</RouteDrawer.Header>
{ready && <EditInventoryItemAttributesForm item={inventoryItem} />}
</RouteDrawer>
)
}

View File

@@ -0,0 +1 @@
export { InventoryItemAttributesEdit as Component } from "./edit-item-attributes-drawer"

View File

@@ -0,0 +1,105 @@
import * as zod from "zod"
import { Button, Input } from "@medusajs/ui"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../../components/route-modal"
import { Form } from "../../../../../../components/common/form"
import { InventoryNext } from "@medusajs/types"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useUpdateInventoryItem } from "../../../../../../hooks/api/inventory"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
type EditInventoryItemFormProps = {
item: InventoryNext.InventoryItemDTO
}
const EditInventoryItemSchema = z.object({
title: z.string().optional(),
sku: z.string().min(1),
})
const getDefaultValues = (item: InventoryNext.InventoryItemDTO) => {
return {
title: item.title ?? undefined,
sku: item.sku ?? undefined,
}
}
export const EditInventoryItemForm = ({ item }: EditInventoryItemFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<zod.infer<typeof EditInventoryItemSchema>>({
defaultValues: getDefaultValues(item),
resolver: zodResolver(EditInventoryItemSchema),
})
const { mutateAsync } = useUpdateInventoryItem(item.id)
const handleSubmit = form.handleSubmit(async (values) => {
mutateAsync(values as any, {
onSuccess: () => {
handleSuccess()
},
})
})
return (
<RouteDrawer.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-8 overflow-auto">
<Form.Field
control={form.control}
name="title"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.title")}</Form.Label>
<Form.Control>
<Input {...field} />
</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} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button type="submit" size="small" isLoading={false}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}

View File

@@ -0,0 +1,33 @@
import { EditInventoryItemForm } from "./components/edit-item-form"
import { Heading } from "@medusajs/ui"
import { RouteDrawer } from "../../../../../components/route-modal"
import { useInventoryItem } from "../../../../../hooks/api/inventory"
import { useParams } from "react-router-dom"
import { useTranslation } from "react-i18next"
export const InventoryItemEdit = () => {
const { id } = useParams()
const { t } = useTranslation()
const {
inventory_item: inventoryItem,
isLoading,
isError,
error,
} = useInventoryItem(id!)
const ready = !isLoading && inventoryItem
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("inventory.editItemDetails")}</Heading>
</RouteDrawer.Header>
{ready && <EditInventoryItemForm item={inventoryItem} />}
</RouteDrawer>
)
}

View File

@@ -0,0 +1 @@
export { InventoryItemEdit as Component } from "./edit-item-drawer"

View File

@@ -0,0 +1,49 @@
import { Container, Heading } from "@medusajs/ui"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { InventoryNext } from "@medusajs/types"
import { PencilSquare } from "@medusajs/icons"
import { SectionRow } from "../../../../../components/common/section"
import { getFormattedCountry } from "../../../../../lib/addresses"
import { useTranslation } from "react-i18next"
type InventoryItemAttributeSectionProps = {
inventoryItem: InventoryNext.InventoryItemDTO
}
export const InventoryItemAttributeSection = ({
inventoryItem,
}: InventoryItemAttributeSectionProps) => {
const { t } = useTranslation()
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("products.attributes")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: "attributes",
icon: <PencilSquare />,
},
],
},
]}
/>
</div>
<SectionRow title={t("fields.height")} value={inventoryItem.height} />
<SectionRow title={t("fields.width")} value={inventoryItem.width} />
<SectionRow title={t("fields.length")} value={inventoryItem.length} />
<SectionRow title={t("fields.weight")} value={inventoryItem.weight} />
<SectionRow title={t("fields.midCode")} value={inventoryItem.mid_code} />
<SectionRow title={t("fields.hsCode")} value={inventoryItem.hs_code} />
<SectionRow
title={t("fields.countryOfOrigin")}
value={getFormattedCountry(inventoryItem.origin_country)}
/>
</Container>
)
}

View File

@@ -0,0 +1,80 @@
import { Container, Heading, Text } from "@medusajs/ui"
import { InventoryNext, ProductVariantDTO } from "@medusajs/types"
import { ActionMenu } from "../../../../components/common/action-menu"
import { InventoryItemRes } from "../../../../types/api-responses"
import { PencilSquare } from "@medusajs/icons"
import { SectionRow } from "../../../../components/common/section"
import { useTranslation } from "react-i18next"
type InventoryItemGeneralSectionProps = {
inventoryItem: InventoryItemRes["inventory_item"] & {
variant: ProductVariantDTO | ProductVariantDTO[]
}
}
export const InventoryItemGeneralSection = ({
inventoryItem,
}: InventoryItemGeneralSectionProps) => {
const { t } = useTranslation()
const variantArray = inventoryItem.variant
? Array.isArray(inventoryItem.variant)
? inventoryItem.variant
: [inventoryItem.variant]
: []
const variantTitles = variantArray.map((variant) => variant.title)
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{inventoryItem.title ?? inventoryItem.sku}</Heading>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: "edit",
},
],
},
]}
/>
</div>
<SectionRow title={t("fields.sku")} value={inventoryItem.sku ?? "-"} />
<SectionRow
title={t("inventory.associatedVariants")}
value={variantTitles?.length ? variantTitles.join(", ") : "-"}
/>
<SectionRow
title={t("fields.inStock")}
value={getQuantityFormat(
inventoryItem.stocked_quantity,
inventoryItem.location_levels.length
)}
/>
<SectionRow
title={t("inventory.reserved")}
value={getQuantityFormat(
inventoryItem.reserved_quantity,
inventoryItem.location_levels.length
)}
/>
<SectionRow
title={t("inventory.available")}
value={getQuantityFormat(
inventoryItem.stocked_quantity - inventoryItem.reserved_quantity,
inventoryItem.location_levels.length
)}
/>
</Container>
)
}
const getQuantityFormat = (quantity: number, locations: number) => {
return `${quantity ?? "-"}
${quantity ? `across ${locations} locations` : ""}`
}

View File

@@ -0,0 +1,28 @@
import { Button, Container, Heading } from "@medusajs/ui"
import { InventoryItemRes } from "../../../../types/api-responses"
import { ItemLocationListTable } from "./location-levels-table/location-list-table"
import { Link } from "react-router-dom"
import { ReservationItemTable } from "./reservations-table/reservation-list-table"
import { useTranslation } from "react-i18next"
type InventoryItemLocationLevelsSectionProps = {
inventoryItem: InventoryItemRes["inventory_item"]
}
export const InventoryItemLocationLevelsSection = ({
inventoryItem,
}: InventoryItemLocationLevelsSectionProps) => {
const { t } = useTranslation()
return (
<Container className="p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("inventory.locationLevels")}</Heading>
<Button size="small" variant="secondary" asChild>
<Link to="locations">{t("inventory.manageLocations")}</Link>
</Button>
</div>
<ItemLocationListTable inventory_item_id={inventoryItem.id} />
</Container>
)
}

View File

@@ -0,0 +1,27 @@
import { Button, Container, Heading } from "@medusajs/ui"
import { InventoryItemRes } from "../../../../types/api-responses"
import { Link } from "react-router-dom"
import { ReservationItemTable } from "./reservations-table/reservation-list-table"
import { useTranslation } from "react-i18next"
type InventoryItemLocationLevelsSectionProps = {
inventoryItem: InventoryItemRes["inventory_item"]
}
export const InventoryItemReservationsSection = ({
inventoryItem,
}: InventoryItemLocationLevelsSectionProps) => {
const { t } = useTranslation()
return (
<Container className="p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("reservations.domain")}</Heading>
<Button size="small" variant="secondary" asChild>
<Link to="locations">{t("actions.create")}</Link>
</Button>
</div>
<ReservationItemTable inventoryItem={inventoryItem} />
</Container>
)
}

View File

@@ -0,0 +1,60 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { InventoryNext } from "@medusajs/types"
import { useDeleteInventoryItemLevel } from "../../../../../hooks/api/inventory"
import { usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
export const LocationActions = ({
level,
}: {
level: InventoryNext.InventoryLevelDTO
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useDeleteInventoryItemLevel(
level.inventory_item_id,
level.location_id
)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("inventory.deleteWarning"),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `locations/${level.location_id}`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}

View File

@@ -0,0 +1,50 @@
import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useInventoryItemLevels } from "../../../../../hooks/api/inventory"
import { useLocationLevelTableQuery } from "./use-location-list-table-query"
import { useLocationListTableColumns } from "./use-location-list-table-columns"
const PAGE_SIZE = 20
export const ItemLocationListTable = ({
inventory_item_id,
}: {
inventory_item_id: string
}) => {
const { searchParams, raw } = useLocationLevelTableQuery({
pageSize: PAGE_SIZE,
})
const { inventory_levels, count, isLoading, isError, error } =
useInventoryItemLevels(inventory_item_id, {
...searchParams,
fields: "*stock_locations",
})
const columns = useLocationListTableColumns()
const { table } = useDataTable({
data: inventory_levels ?? [],
columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
})
if (isError) {
throw error
}
return (
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
isLoading={isLoading}
pagination
queryObject={raw}
/>
)
}

View File

@@ -0,0 +1,97 @@
import { InventoryNext, StockLocationDTO } from "@medusajs/types"
import { LocationActions } from "./location-actions"
import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
/**
* Adds missing properties to the InventoryLevelDTO type.
*/
interface ExtendedLocationLevel extends InventoryNext.InventoryLevelDTO {
stock_locations: StockLocationDTO[]
reserved_quantity: number
stocked_quantity: number
available_quantity: number
}
const columnHelper = createColumnHelper<ExtendedLocationLevel>()
export const useLocationListTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("stock_locations.0.name", {
header: t("fields.location"),
cell: ({ getValue }) => {
const locationName = getValue()
if (!locationName) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{locationName.toString()}</span>
</div>
)
},
}),
columnHelper.accessor("reserved_quantity", {
header: t("inventory.reserved"),
cell: ({ getValue }) => {
const quantity = getValue()
if (Number.isNaN(quantity)) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{quantity}</span>
</div>
)
},
}),
columnHelper.accessor("stocked_quantity", {
header: t("fields.inStock"),
cell: ({ getValue }) => {
const stockedQuantity = getValue()
if (Number.isNaN(stockedQuantity)) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{stockedQuantity}</span>
</div>
)
},
}),
columnHelper.accessor("available_quantity", {
header: t("inventory.available"),
cell: ({ getValue }) => {
const availableQuantity = getValue()
if (Number.isNaN(availableQuantity)) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{availableQuantity}</span>
</div>
)
},
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <LocationActions level={row.original} />,
}),
],
[t]
)
}

View File

@@ -0,0 +1,44 @@
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useLocationLevelTableQuery = ({
pageSize = 20,
prefix,
}: {
pageSize?: number
prefix?: string
}) => {
const raw = useQueryParams(
[
"id",
"location_id",
"stocked_quantity",
"reserved_quantity",
"incoming_quantity",
"available_quantity",
"*stock_locations",
],
prefix
)
const { reserved_quantity, stocked_quantity, available_quantity, ...params } =
raw
const searchParams = {
limit: pageSize,
reserved_quantity: reserved_quantity
? JSON.parse(reserved_quantity)
: undefined,
stocked_quantity: stocked_quantity
? JSON.parse(stocked_quantity)
: undefined,
available_quantity: available_quantity
? JSON.parse(available_quantity)
: undefined,
...params,
}
return {
searchParams,
raw,
}
}

View File

@@ -0,0 +1,42 @@
import { Checkbox, Text, clx } from "@medusajs/ui"
import { StockLocationDTO } from "@medusajs/types"
type LocationItemProps = {
selected: boolean
onSelect: (selected: boolean) => void
location: StockLocationDTO
}
export const LocationItem = ({
selected,
onSelect,
location,
}: LocationItemProps) => {
return (
<div
className={clx("flex w-full rounded-lg border px-2 py-2 gap-x-2", {
"border-ui-border-interactive ": selected,
})}
onClick={() => onSelect(!selected)}
>
<div className="h-5 w-5">
<Checkbox checked={selected} />
</div>
<div className="flex w-full flex-col">
<Text size="small" leading="compact" weight="plus">
{location.name}
</Text>
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{[
location.address?.address_1,
location.address?.city,
location.address?.country_code,
]
.filter((el) => !!el)
.join(", ")}
</Text>
</div>
</div>
)
}

View File

@@ -0,0 +1,184 @@
import * as zod from "zod"
import { Button, Table, Text } from "@medusajs/ui"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../../components/route-modal"
import {
useBatchInventoryItemLevels,
useUpdateInventoryItem,
} from "../../../../../../hooks/api/inventory"
import { useFieldArray, useForm } from "react-hook-form"
import { InventoryItemRes } from "../../../../../../types/api-responses"
import { LocationItem } from "./location-item"
import { StockLocationDTO } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
type EditInventoryItemAttributeFormProps = {
item: InventoryItemRes["inventory_item"]
locations: StockLocationDTO[]
}
const EditInventoryItemAttributesSchema = z.object({
locations: z.array(
z.object({
id: z.string(),
location_id: z.string(),
selected: z.boolean(),
})
),
})
const getDefaultValues = (
allLocations: StockLocationDTO[],
existinLevels: Set<string>
) => {
return {
locations: allLocations.map((location) => ({
...location,
location_id: location.id,
selected: existinLevels.has(location.id),
})),
}
}
export const ManageLocationsForm = ({
item,
locations,
}: EditInventoryItemAttributeFormProps) => {
const existingLocationLevels = new Set(
item.location_levels?.map((l) => l.location_id) ?? []
)
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<zod.infer<typeof EditInventoryItemAttributesSchema>>({
defaultValues: getDefaultValues(locations, existingLocationLevels),
resolver: zodResolver(EditInventoryItemAttributesSchema),
})
const { fields: locationFields, update: updateField } = useFieldArray({
control: form.control,
name: "locations",
})
const { mutateAsync } = useBatchInventoryItemLevels(item.id)
const handleSubmit = form.handleSubmit(async ({ locations }) => {
// Changes in selected locations
const [selectedLocations, unselectedLocations] = locations.reduce(
(acc, location) => {
// If the location is not changed do nothing
if (
(!location.selected &&
!existingLocationLevels.has(location.location_id)) ||
(location.selected &&
existingLocationLevels.has(location.location_id))
) {
return acc
}
if (location.selected) {
acc[0].push(location.location_id)
} else {
acc[1].push(location.location_id)
}
return acc
},
[[], []] as [string[], string[]]
)
if (selectedLocations.length === 0 && unselectedLocations.length === 0) {
return handleSuccess()
}
await mutateAsync({
creates: selectedLocations.map((location_id) => ({
location_id,
})),
deletes: unselectedLocations,
})
return handleSuccess()
})
return (
<RouteDrawer.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-4 overflow-auto">
<div className="grid grid-rows-2 divide-y rounded-lg border text-ui-fg-subtle shadow-elevation-card-rest">
<div className="grid grid-cols-2 divide-x">
<Text className="px-2 py-1.5" size="small" leading="compact">
{t("fields.title")}
</Text>
<Text className="px-2 py-1.5" size="small" leading="compact">
{item.title ?? "-"}
</Text>
</div>
<div className="grid grid-cols-2 divide-x">
<Text className="px-2 py-1.5" size="small" leading="compact">
{t("fields.sku")}
</Text>
<Text className="px-2 py-1.5" size="small" leading="compact">
{item.sku}
</Text>
</div>
</div>
<div className="flex flex-col">
<Text size="small" weight="plus" leading="compact">
{t("locations.domain")}
</Text>
<div className="flex w-full justify-between text-ui-fg-subtle">
<Text size="small" leading="compact">
{t("locations.selectLocations")}
</Text>
<Text size="small" leading="compact">
{"("}
{t("general.countOfTotalSelected", {
count: locationFields.filter((l) => l.selected).length,
total: locations.length,
})}
{")"}
</Text>
</div>
</div>
{locationFields.map((location, idx) => {
return (
<LocationItem
selected={location.selected}
location={location as any}
onSelect={() =>
updateField(idx, {
...location,
selected: !location.selected,
})
}
key={location.id}
/>
)
})}
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button type="submit" size="small" isLoading={false}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}

View File

@@ -0,0 +1 @@
export { ManageLocationsDrawer as Component } from "./manage-locations-drawer"

View File

@@ -0,0 +1,39 @@
import { Heading } from "@medusajs/ui"
import { ManageLocationsForm } from "./components/manage-locations-form"
import { RouteDrawer } from "../../../../../components/route-modal"
import { useInventoryItem } from "../../../../../hooks/api/inventory"
import { useParams } from "react-router-dom"
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { useTranslation } from "react-i18next"
export const ManageLocationsDrawer = () => {
const { id } = useParams()
const { t } = useTranslation()
const {
inventory_item: inventoryItem,
isLoading,
isError,
error,
} = useInventoryItem(id!)
const { stock_locations, isLoading: loadingLocations } = useStockLocations()
const ready =
!isLoading && !loadingLocations && inventoryItem && stock_locations
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("inventory.manageLocations")}</Heading>
</RouteDrawer.Header>
{ready && (
<ManageLocationsForm item={inventoryItem} locations={stock_locations} />
)}
</RouteDrawer>
)
}

View File

@@ -0,0 +1,57 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { InventoryNext } from "@medusajs/types"
import { useDeleteReservationItem } from "../../../../../hooks/api/inventory"
import { usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
export const ReservationActions = ({
reservation,
}: {
reservation: InventoryNext.ReservationItemDTO
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useDeleteReservationItem(reservation.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("inventory.deleteWarning"),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/reservation/${reservation.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}

View File

@@ -0,0 +1,51 @@
import { DataTable } from "../../../../../components/table/data-table"
import { InventoryNext } from "@medusajs/types"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useInventoryTableColumns } from "./use-reservation-list-table-columns"
import { useInventoryTableQuery } from "./use-reservation-list-table-query"
import { useReservationItems } from "../../../../../hooks/api/inventory"
const PAGE_SIZE = 20
export const ReservationItemTable = ({
inventoryItem,
}: {
inventoryItem: InventoryNext.InventoryItemDTO
}) => {
const { searchParams, raw } = useInventoryTableQuery({
pageSize: PAGE_SIZE,
})
const { reservations, count, isLoading, isError, error } =
useReservationItems({
...searchParams,
inventory_item_id: [inventoryItem.id],
})
const columns = useInventoryTableColumns({ sku: inventoryItem.sku! })
const { table } = useDataTable({
data: reservations ?? [],
columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
})
if (isError) {
throw error
}
return (
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
isLoading={isLoading}
pagination
queryObject={raw}
/>
)
}

View File

@@ -0,0 +1,107 @@
import { InventoryNext, StockLocationDTO } from "@medusajs/types"
import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell"
import { ReservationActions } from "./reservation-actions"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
/**
* Adds missing properties to the InventoryItemDTO type.
*/
interface ExtendedInventoryItem extends InventoryNext.ReservationItemDTO {
line_item: { order_id: string }
location: StockLocationDTO
}
const columnHelper = createColumnHelper<ExtendedInventoryItem>()
export const useInventoryTableColumns = ({ sku }: { sku: string }) => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
header: t("fields.sku"),
cell: () => {
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{sku}</span>
</div>
)
},
}),
columnHelper.accessor("line_item.order_id", {
header: t("inventory.reserved"),
cell: ({ getValue }) => {
const quantity = getValue()
if (Number.isNaN(quantity)) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{quantity}</span>
</div>
)
},
}),
columnHelper.accessor("description", {
header: t("fields.description"),
cell: ({ getValue }) => {
const description = getValue()
if (!description) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{description}</span>
</div>
)
},
}),
columnHelper.accessor("location.name", {
header: t("inventory.location"),
cell: ({ getValue }) => {
const location = getValue()
if (!location) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{location}</span>
</div>
)
},
}),
columnHelper.accessor("created_at", {
header: t("fields.createdAt"),
cell: ({ getValue }) => {
const createdAt = getValue()
if (!createdAt) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">
{createdAt instanceof Date ? createdAt.toString() : createdAt}
</span>
</div>
)
},
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <ReservationActions item={row.original} />,
}),
],
[t]
)
}

View File

@@ -0,0 +1,35 @@
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useInventoryTableQuery = ({
pageSize = 20,
prefix,
}: {
pageSize?: number
prefix?: string
}) => {
const raw = useQueryParams(
[
"id",
"location_id",
"inventory_item_id",
"quantity",
"line_item_id",
"description",
"created_by",
],
prefix
)
const { quantity, ...params } = raw
const searchParams = {
limit: pageSize,
quantity: quantity ? JSON.parse(quantity) : undefined,
...params,
}
return {
searchParams,
raw,
}
}

View File

@@ -0,0 +1,2 @@
export { InventoryDeatil as Component } from "./inventory-detail"
export { inventoryItemLoader as loader } from "./loader"

View File

@@ -0,0 +1,59 @@
import { Outlet, json, useLoaderData, useParams } from "react-router-dom"
import { InventoryItemAttributeSection } from "./components/inventory-item-attributes/attributes-section"
import { InventoryItemGeneralSection } from "./components/inventory-item-general-section"
import { InventoryItemLocationLevelsSection } from "./components/inventory-item-location-levels"
import { InventoryItemReservationsSection } from "./components/inventory-item-reservations"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { inventoryItemLoader } from "./loader"
import { useInventoryItem } from "../../../hooks/api/inventory"
export const InventoryDeatil = () => {
const { id } = useParams()
const initialData = useLoaderData() as Awaited<
ReturnType<typeof inventoryItemLoader>
>
const { inventory_item, isLoading, isError, error } = useInventoryItem(
id!,
{},
{
initialData,
}
)
if (isLoading) {
return <div>Loading...</div>
}
if (isError || !inventory_item) {
if (error) {
throw error
}
throw json("An unknown error occurred", 500)
}
return (
<div className="flex flex-col gap-y-2">
<div className="flex flex-col gap-x-4 lg:flex-row lg:items-start">
<div className="w-full flex flex-col gap-y-2">
<InventoryItemGeneralSection inventoryItem={inventory_item} />
<InventoryItemLocationLevelsSection inventoryItem={inventory_item} />
<InventoryItemReservationsSection inventoryItem={inventory_item} />
<div className="hidden lg:block">
<JsonViewSection data={inventory_item} />
</div>
<Outlet />
</div>
<div className="w-full lg:max-w-[400px] max-w-[100%] mt-2 lg:mt-0 flex flex-col gap-y-2">
<InventoryItemAttributeSection inventoryItem={inventory_item} />
<div className="lg:hidden">
<JsonViewSection data={inventory_item} />
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { InventoryItemRes } from "../../../types/api-responses"
import { LoaderFunctionArgs } from "react-router-dom"
import { client } from "../../../lib/client"
import { inventoryItemsQueryKeys } from "../../../hooks/api/inventory"
import { queryClient } from "../../../lib/medusa"
const inventoryDetailQuery = (id: string) => ({
queryKey: inventoryItemsQueryKeys.detail(id),
queryFn: async () =>
client.inventoryItems.retrieve(id, {
fields: "*variants",
}),
})
export const inventoryItemLoader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id
const query = inventoryDetailQuery(id!)
return (
queryClient.getQueryData<InventoryItemRes>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}

View File

@@ -0,0 +1,53 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { ActionMenu } from "../../../../components/common/action-menu"
import { InventoryItemDTO } from "@medusajs/types"
import { useDeleteInventoryItem } from "../../../../hooks/api/inventory"
import { usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
export const InventoryActions = ({ item }: { item: InventoryItemDTO }) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useDeleteInventoryItem(item.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("inventory.deleteWarning"),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `${item.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}

View File

@@ -0,0 +1,61 @@
import { Container, Heading } from "@medusajs/ui"
import { DataTable } from "../../../../components/table/data-table"
import { InventoryNext } from "@medusajs/types"
import { useDataTable } from "../../../../hooks/use-data-table"
import { useInventoryItems } from "../../../../hooks/api/inventory"
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"
const PAGE_SIZE = 20
export const InventoryListTable = () => {
const { t } = useTranslation()
const { searchParams, raw } = useInventoryTableQuery({
pageSize: PAGE_SIZE,
})
const { inventory_items, count, isLoading, isError, error } =
useInventoryItems({
...searchParams,
})
const filters = useInventoryTableFilters()
const columns = useInventoryTableColumns()
const { table } = useDataTable({
data: (inventory_items ?? []) as InventoryNext.InventoryItemDTO[],
columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
})
if (isError) {
throw error
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("inventory.domain")}</Heading>
</div>
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
isLoading={isLoading}
pagination
search
filters={filters}
queryObject={raw}
orderBy={["title", "sku", "stocked_quantity", "reserved_quantity"]}
navigateTo={(row) => `${row.id}`}
/>
</Container>
)
}

View File

@@ -0,0 +1,96 @@
import { InventoryNext, ProductVariantDTO } from "@medusajs/types"
import { InventoryActions } from "./inventory-actions"
import { PlaceholderCell } from "../../../../components/table/table-cells/common/placeholder-cell"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
/**
* Adds missing properties to the InventoryItemDTO type.
*/
interface ExtendedInventoryItem extends InventoryNext.InventoryItemDTO {
variants?: ProductVariantDTO[] | null
stocked_quantity?: number
reserved_quantity?: number
}
const columnHelper = createColumnHelper<ExtendedInventoryItem>()
export const useInventoryTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("title", {
header: t("fields.title"),
cell: ({ getValue }) => {
const title = getValue()
if (!title) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{title}</span>
</div>
)
},
}),
columnHelper.accessor("sku", {
header: t("fields.sku"),
cell: ({ getValue }) => {
const sku = getValue() as string
if (!sku) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{sku}</span>
</div>
)
},
}),
columnHelper.accessor("reserved_quantity", {
header: t("inventory.reserved"),
cell: ({ getValue }) => {
const quantity = getValue()
if (Number.isNaN(quantity)) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{quantity}</span>
</div>
)
},
}),
columnHelper.accessor("stocked_quantity", {
header: t("fields.inStock"),
cell: ({ getValue }) => {
const quantity = getValue()
if (Number.isNaN(quantity)) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{quantity}</span>
</div>
)
},
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <InventoryActions item={row.original} />,
}),
],
[t]
)
}

View File

@@ -0,0 +1,82 @@
import { Filter } from "../../../../components/table/data-table"
import { useStockLocations } from "../../../../hooks/api/stock-locations"
import { useTranslation } from "react-i18next"
export const useInventoryTableFilters = () => {
const { t } = useTranslation()
const { stock_locations } = useStockLocations({
limit: 1000,
})
const filters: Filter[] = []
if (stock_locations) {
const stockLocationFilter: Filter = {
type: "select",
options: stock_locations.map((s) => ({
label: s.name,
value: s.id,
})),
key: "location_id",
searchable: true,
label: t("fields.location"),
}
filters.push(stockLocationFilter)
}
filters.push({
type: "string",
key: "material",
label: t("fields.material"),
})
filters.push({
type: "string",
key: "sku",
label: t("fields.sku"),
})
filters.push({
type: "string",
key: "mid_code",
label: t("fields.midCode"),
})
filters.push({
type: "number",
key: "height",
label: t("fields.height"),
})
filters.push({
type: "number",
key: "width",
label: t("fields.width"),
})
filters.push({
type: "number",
key: "length",
label: t("fields.length"),
})
filters.push({
type: "number",
key: "weight",
label: t("fields.weight"),
})
filters.push({
type: "select",
options: [
{ label: t("fields.true"), value: "true" },
{ label: t("fields.false"), value: "false" },
],
key: "requires_shipping",
multiple: false,
label: t("fields.requiresShipping"),
})
return filters
}

View File

@@ -0,0 +1,57 @@
import { AdminGetInventoryItemsParams } from "@medusajs/medusa"
import { useQueryParams } from "../../../../hooks/use-query-params"
export const useInventoryTableQuery = ({
pageSize = 20,
prefix,
}: {
pageSize?: number
prefix?: string
}) => {
const raw = useQueryParams(
[
"location_id",
"q",
"order",
"requires_shipping",
"offset",
"sku",
"material",
"mid_code",
"order",
"weight",
"width",
"length",
"height",
],
prefix
)
const {
offset,
weight,
width,
length,
height,
requires_shipping,
...params
} = raw
const searchParams: AdminGetInventoryItemsParams = {
limit: pageSize,
offset: offset ? parseInt(offset) : undefined,
weight: weight ? JSON.parse(weight) : undefined,
width: width ? JSON.parse(width) : undefined,
length: length ? JSON.parse(length) : undefined,
height: height ? JSON.parse(height) : undefined,
requires_shipping: requires_shipping
? JSON.parse(requires_shipping)
: undefined,
...params,
}
return {
searchParams,
raw,
}
}

View File

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

View File

@@ -0,0 +1,11 @@
import { InventoryListTable } from "./components/inventory-list-table"
import { Outlet } from "react-router-dom"
export const InventoryItemListTable = () => {
return (
<div className="flex flex-col gap-y-2">
<InventoryListTable />
<Outlet />
</div>
)
}